diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d3d0ccf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,209 @@ +# With more recent updates Visual Studio 2017 supports EditorConfig files out of the box +# Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode +# For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig + +############################### +# Core EditorConfig Options # +############################### +root = true + +# All files +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +max_line_length = off + +# YAML indentation +[*.{yml,yaml}] +indent_size = 2 + +# XML indentation +[*.{csproj,xml}] +indent_size = 2 + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are camelCase and start with _ +dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = camel_case +dotnet_naming_style.static_field_style.required_prefix = _ + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true diff --git a/.github/RELEASE_PROCESS.md b/.github/RELEASE_PROCESS.md deleted file mode 100644 index ced7f0b..0000000 --- a/.github/RELEASE_PROCESS.md +++ /dev/null @@ -1,37 +0,0 @@ -# Release pipeline (internal) - -This document is for repository maintainers only. It describes the GitHub Actions workflow used to build and publish the plugin. - -## Workflow path - -- `.github/workflows/publish-plugin.yml` - -## Behavior - -- Triggered on `push` to tags matching `v*` and manual dispatch. -- Uses `Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj` `` as source of truth. -- On tag builds, validates that tag `vX.Y.Z.W` matches csproj `` exactly. -- On manual runs, uses csproj `` (optional input `version` can be provided only as a validation check). -- Builds and publishes the project with `dotnet publish`. -- Packages a ZIP containing: - - `Jellyfin.Database.Providers.Postgres.dll` - - `Npgsql.dll` - - `Npgsql.EntityFrameworkCore.PostgreSQL.dll` -- Regenerates `manifest.json` with resolved version, checksum, timestamp, source URL, and metadata read from `build.yaml` (guid/name/description/overview/owner/category/targetAbi). -- Commits the updated `manifest.json` back to `main`. -- Creates a GitHub Release (tag-triggered runs only) and uploads both assets: - - `Jellyfin.Database.Providers.Postgres.zip` - - `manifest.json` - -## Recommended release flow - -1. Update version in `Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj`: - - `` (single source of truth) -2. Update `build.yaml` only when metadata changes (for example `targetAbi`, name, description, owner, category). -3. Commit and push to `main`. -4. Create and push matching tag: - - `git tag vX.Y.Z.W` - - `git push origin vX.Y.Z.W` -5. Workflow publishes ZIP + manifest and creates the GitHub Release. - -If tag and csproj version do not match, the workflow fails intentionally to prevent inconsistent releases. diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..e7c31bb --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "matchPackageNames": [ + "Jellyfin.Controller", + "Jellyfin.Model", + "Jellyfin.Data" + ], + "groupName": "Jellyfin packages" + } + ] +} diff --git a/.github/scripts/generate_release_metadata.py b/.github/scripts/generate_release_metadata.py deleted file mode 100644 index 6c04e25..0000000 --- a/.github/scripts/generate_release_metadata.py +++ /dev/null @@ -1,133 +0,0 @@ -import hashlib -import json -import os -import re -from datetime import datetime, timezone -from pathlib import Path - - -def _extract_scalar(text: str, key: str) -> str: - match = re.search(rf'^{re.escape(key)}:\s*"?([^"\n]+)"?\s*$', text, flags=re.MULTILINE) - if not match: - raise ValueError(f"Missing '{key}' in build.yaml") - return match.group(1).strip() - - -def _extract_block(text: str, key: str) -> str: - pattern = rf'^{re.escape(key)}:\s*>\s*$' - match = re.search(pattern, text, flags=re.MULTILINE) - if not match: - raise ValueError(f"Missing block '{key}: >' in build.yaml") - - lines = text[match.end():].splitlines() - block_lines: list[str] = [] - for line in lines: - if not line.strip(): - if block_lines: - break - continue - - if line.startswith(" "): - block_lines.append(line.strip()) - continue - - break - - if not block_lines: - raise ValueError(f"Block '{key}' is empty in build.yaml") - - return " ".join(block_lines) - - -def load_build_metadata(path: Path) -> dict: - text = path.read_text(encoding="utf-8") - return { - "name": _extract_scalar(text, "name"), - "guid": _extract_scalar(text, "guid"), - "targetAbi": _extract_scalar(text, "targetAbi"), - "overview": _extract_scalar(text, "overview"), - "owner": _extract_scalar(text, "owner"), - "category": _extract_scalar(text, "category"), - "description": _extract_block(text, "description"), - } - - -def main() -> None: - version = os.environ.get("VERSION", "0.0.0") - if version.startswith("v"): - version = version[1:] - - build_meta = load_build_metadata(Path("build.yaml")) - - asset_name = "Jellyfin.Database.Providers.Postgres.zip" - source_url = ( - "https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/" - f"releases/download/v{version}/{asset_name}" - ) - image_url = ( - "https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/" - f"v{version}/Jellyfin.Database.Providers.Postgres/Resources/logo.png" - ) - release_dir = Path("release") - zip_path = release_dir / asset_name - - if not zip_path.exists(): - raise FileNotFoundError(f"Missing release asset: {zip_path}") - - checksum = hashlib.md5(zip_path.read_bytes()).hexdigest() - - new_version_entry = { - "version": version, - "changelog": "", - "targetAbi": build_meta["targetAbi"], - "sourceUrl": source_url, - "checksum": checksum, - "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - - # Load existing manifest from the repo root and preserve version history. - # Only entries for OTHER versions are kept; the current version is replaced/added - # at the front so Jellyfin always sees the latest release first. - existing_versions: list = [] - manifest_path = Path("manifest.json") - if manifest_path.exists(): - try: - existing_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - if isinstance(existing_manifest, list) and existing_manifest: - existing_versions = [ - v for v in existing_manifest[0].get("versions", []) - if v.get("version") != version - ] - except (json.JSONDecodeError, KeyError, IndexError): - existing_versions = [] - - # New version goes first; older versions follow in their original order. - all_versions = [new_version_entry] + existing_versions - - manifest = [ - { - "guid": build_meta["guid"], - "name": build_meta["name"], - "description": build_meta["description"], - "overview": build_meta["overview"], - "owner": build_meta["owner"], - "category": build_meta["category"], - "imageUrl": image_url, - "versions": all_versions, - } - ] - - release_dir.mkdir(exist_ok=True) - - (release_dir / "manifest.json").write_text( - json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - manifest_path.write_text( - json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..0c2d779 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,43 @@ +name: '🏗️ Build Plugin' + +on: + push: + branches: + - main + - 'release/*' + paths-ignore: + - '**/*.md' + pull_request: + branches: + - main + paths-ignore: + - '**/*.md' + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore Jellyfin.Database.Providers.Postgres.sln + + - name: Build (Release) + run: dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release --no-restore + + - name: Publish + run: dotnet publish Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release --no-restore -o artifacts + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Jellyfin.Database.Providers.Postgres + path: artifacts/ diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml deleted file mode 100644 index 6aba6a9..0000000 --- a/.github/workflows/publish-plugin.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: Publish Jellyfin PostgreSQL Plugin - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Optional: version to validate against csproj on manual runs (example: 1.0.0.0)' - required: false - type: string - -jobs: - build: - name: Build and Package Plugin - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: '9.0.x' - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: .NET diagnostics - run: | - dotnet --info - dotnet --list-sdks - dotnet nuget list source - - - name: Determine plugin version - id: version - shell: bash - run: | - CSPROJ_VERSION=$(python - <<'PY' - import re - from pathlib import Path - text = Path("Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj").read_text(encoding="utf-8") - match = re.search(r"\s*([^<\s]+)\s*", text) - if not match: - raise SystemExit("Missing in Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj") - print(match.group(1)) - PY - ) - - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - TAG_VERSION="${GITHUB_REF#refs/tags/}" - PACKAGE_VERSION="${TAG_VERSION#v}" - if [[ "${PACKAGE_VERSION}" != "${CSPROJ_VERSION}" ]]; then - echo "Tag version (${PACKAGE_VERSION}) does not match csproj (${CSPROJ_VERSION})." >&2 - echo "Update Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj first, then tag v${CSPROJ_VERSION}." >&2 - exit 1 - fi - else - PACKAGE_VERSION="${CSPROJ_VERSION}" - TAG_VERSION="v${PACKAGE_VERSION}" - if [[ -n "${{ github.event.inputs.version }}" && "${{ github.event.inputs.version }}" != "${PACKAGE_VERSION}" ]]; then - echo "Manual input version (${{ github.event.inputs.version }}) does not match csproj (${PACKAGE_VERSION})." >&2 - exit 1 - fi - fi - - echo "Resolved version from csproj: ${PACKAGE_VERSION}" - echo "TAG_VERSION=${TAG_VERSION}" >> "$GITHUB_ENV" - echo "VERSION=${PACKAGE_VERSION}" >> "$GITHUB_ENV" - - - name: Restore dependencies (with retry) - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - echo "Restore attempt ${attempt}/3" - if dotnet restore Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj --verbosity normal; then - exit 0 - fi - if [[ "$attempt" -lt 3 ]]; then - echo "Restore failed; retrying in 10s..." - sleep 10 - fi - done - echo "dotnet restore failed after 3 attempts" >&2 - exit 1 - - - name: Build plugin - run: dotnet build Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release /p:ContinuousIntegrationBuild=true /p:Version=$VERSION - - - name: Publish plugin output - run: dotnet publish Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release -o ./publish_out /p:Version=$VERSION - - - name: Package plugin ZIP - run: | - mkdir -p release - cd publish_out - zip -j ../release/Jellyfin.Database.Providers.Postgres.zip \ - Jellyfin.Database.Providers.Postgres.dll \ - Npgsql.dll \ - Npgsql.EntityFrameworkCore.PostgreSQL.dll - cd .. - - - name: Generate release metadata and manifest - run: python .github/scripts/generate_release_metadata.py - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: jellyfin-postgres-plugin - path: release/ - - - name: Commit manifest update via GitHub API - env: - GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - # Read the newly-generated manifest and encode it as base64 for the API. - CONTENT=$(base64 -w 0 manifest.json) - - # Get the current blob SHA of manifest.json on main — required by the API to - # perform an update (prevents accidental overwrites). - BLOB_SHA=$(curl -sf \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/contents/manifest.json" \ - | jq -r '.sha') - - # Update the file atomically via the Contents API. This never has git merge - # conflicts regardless of concurrent CI runs or manual pushes. - HTTP_STATUS=$(curl -s -o /tmp/api_response.json -w "%{http_code}" \ - -X PUT \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/contents/manifest.json" \ - -d "{ - \"message\": \"Update manifest to ${TAG_VERSION} [ci skip]\", - \"content\": \"${CONTENT}\", - \"sha\": \"${BLOB_SHA}\" - }") - - if [[ "$HTTP_STATUS" -ge 200 && "$HTTP_STATUS" -lt 300 ]]; then - echo "manifest.json updated on main (HTTP ${HTTP_STATUS})" - else - echo "GitHub API returned HTTP ${HTTP_STATUS}:" - cat /tmp/api_response.json - exit 1 - fi - - release: - name: Create GitHub Release - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download packaged release - uses: actions/download-artifact@v4 - with: - name: jellyfin-postgres-plugin - path: release_artifact - - - name: Create GitHub release with ZIP asset - uses: softprops/action-gh-release@v1 - with: - files: | - release_artifact/Jellyfin.Database.Providers.Postgres.zip - release_artifact/manifest.json - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f2ac1c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,114 @@ +name: '📦 Release Plugin' + +on: + workflow_dispatch: + inputs: + release_version: + description: 'Versión del release (ej: 2.0.0 o 2.0.0.0)' + required: true + type: string + changelog: + description: 'Notas de la versión (se muestran en el catálogo de Jellyfin y en el release)' + required: true + type: string + prerelease: + description: 'Publicar como prerelease (para pruebas)' + required: false + default: false + type: boolean + +permissions: + contents: write + +jobs: + release: + name: Build & Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Normalizar versión (4 partes) + id: version + run: | + V="${{ github.event.inputs.release_version }}" + PARTS=$(echo "$V" | awk -F. '{print NF}') + if [ "$PARTS" -eq 1 ]; then V="${V}.0.0.0" + elif [ "$PARTS" -eq 2 ]; then V="${V}.0.0" + elif [ "$PARTS" -eq 3 ]; then V="${V}.0" + fi + echo "full=$V" >> "$GITHUB_OUTPUT" + + - name: Fijar versión en Directory.Build.props + run: | + sed -i "s#[^<]*#${{ steps.version.outputs.full }}#" Directory.Build.props + sed -i "s#[^<]*#${{ steps.version.outputs.full }}#" Directory.Build.props + sed -i "s#[^<]*#${{ steps.version.outputs.full }}#" Directory.Build.props + + - name: Restore + run: dotnet restore Jellyfin.Database.Providers.Postgres.sln + + - name: Build (Release) + run: dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release --no-restore + + - name: Publicar plugin + run: dotnet publish Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release --no-build -o publish + + - name: Empaquetar ZIP + checksum + id: package + run: | + cd publish + # DLLs que Jellyfin NO provee en runtime y deben ir en el ZIP: + # Jellyfin.Database.Providers.Postgres.dll — el plugin + # Npgsql.dll — driver Npgsql + # Npgsql.EntityFrameworkCore.PostgreSQL.dll — provider EF Core para PG + zip -j "../Jellyfin.Database.Providers.Postgres-${{ steps.version.outputs.full }}.zip" \ + Jellyfin.Database.Providers.Postgres.dll \ + Npgsql.dll \ + Npgsql.EntityFrameworkCore.PostgreSQL.dll + cd .. + # Jellyfin valida el checksum con MD5 (InstallationManager.PerformPackageInstallation). + CHECKSUM=$(md5sum "Jellyfin.Database.Providers.Postgres-${{ steps.version.outputs.full }}.zip" | awk '{print $1}') + echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT" + + - name: Actualizar manifest.json (repo) + run: | + VERSION="${{ steps.version.outputs.full }}" + ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" + URL="https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.release_version }}/${ZIP}" + CHECKSUM="${{ steps.package.outputs.checksum }}" + TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + CHANGELOG="${{ github.event.inputs.changelog }}" + 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}])' \ + manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json + cp manifest.json publish/manifest.json + + - name: Commit manifest actualizado + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add manifest.json Directory.Build.props + git diff --cached --quiet || git commit -m "chore: actualizar manifest a v${{ steps.version.outputs.full }}" + git push + + - name: Preparar notas del release + run: printf '%s' "${{ github.event.inputs.changelog }}" > release-notes.md + + - name: Crear release en GitHub + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ github.event.inputs.release_version }} + name: PostgreSQL Database Provider ${{ github.event.inputs.release_version }} + body_path: release-notes.md + prerelease: ${{ github.event.inputs.prerelease }} + files: | + Jellyfin.Database.Providers.Postgres-${{ steps.version.outputs.full }}.zip + publish/manifest.json diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml new file mode 100644 index 0000000..05216d6 --- /dev/null +++ b/.github/workflows/scan-codeql.yaml @@ -0,0 +1,34 @@ +name: '🔬 Run CodeQL' + +on: + push: + branches: [ main ] + paths-ignore: + - '**/*.md' + pull_request: + branches: [ main ] + paths-ignore: + - '**/*.md' + schedule: + - cron: '0 0 1 * *' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index 3668c63..68856f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,55 @@ -# Build artifacts -[Bb]in/ -[Oo]bj/ -publish_out/ -release/ -artifacts/ +# Build Folders +bin/ +obj/ -# IDE/editor state +# IA Assistant +.claude/ + +# Visual Studio / Rider .vs/ -.vscode/ .idea/ *.suo *.user *.userosscache *.sln.docstates +*.sln.iml -# Logs and diagnostics -*.log -*.tmp -*.temp +# OS generated files +.DS_Store +Thumbs.db + +# NuGet +*.nupkg +**/packages/* +!**/packages/build/ + +# Visual Studio cache/options +*.aps *.cache +*.dbmdl +*.bak +*.backup +*.log +*.vspscc +*.vssscc -# Test and coverage output -TestResults/ -coverage/ -*.coverage -*.coveragexml +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ -# NuGet/package output -packages/ -*.nupkg -*.snupkg +# Publish output +publish/ +publish_out/ +release/ +*.publish.xml -# OS-specific noise -.DS_Store -Thumbs.db +# Local secrets +secrets.json +.local.settings.json +.env + +# Temp / scratch +.tmp/ +cleanup*.sql +diag.sql diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a65a3ee --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Jellyfin", + "type": "coreclr", + "request": "attach", + "processName": "jellyfin", + "justMyCode": false + }, + { + "name": "Launch Jellyfin (Debug Plugin)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-and-copy", + "program": "${config:jellyfinDir}/jellyfin.exe", + "args": [], + "cwd": "${config:jellyfinDir}", + "console": "internalConsole", + "justMyCode": false + } + ] +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..6f259b9 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "pg-aiguide": { + "url": "https://mcp.tigerdata.com/docs", + "type": "http" + } + }, + "inputs": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..269e0b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + // jellyfinDir: directorio del servidor Jellyfin portable + "jellyfinDir": "D:/jellyfin-portable", + // 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" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9392289 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,62 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-and-copy", + "dependsOrder": "sequence", + "dependsOn": ["build", "make-plugin-dir", "copy-dll"], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "build", + "command": "dotnet", + "type": "shell", + "args": [ + "build", + "${workspaceFolder}/${config:pluginName}.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "--configuration", + "Debug" + ], + "group": "build", + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "make-plugin-dir", + "type": "shell", + "command": "New-Item", + "args": [ + "-ItemType", + "Directory", + "-Force", + "-Path", + "${config:jellyfinDataDir}/plugins/${config:pluginName}/" + ], + "presentation": { + "reveal": "silent" + } + }, + { + "label": "copy-dll", + "type": "shell", + "command": "Copy-Item", + "args": [ + "-Force", + "-Path", + "${workspaceFolder}/${config:pluginName}/bin/Debug/net9.0/*", + "-Destination", + "${config:jellyfinDataDir}/plugins/${config:pluginName}/" + ], + "presentation": { + "reveal": "silent" + } + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..fe2323d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + 2.0.0.0 + 2.0.0.0 + 2.0.0.0 + + diff --git a/Jellyfin.Database.Providers.Postgres.sln b/Jellyfin.Database.Providers.Postgres.sln index 4946f66..21bb076 100644 --- a/Jellyfin.Database.Providers.Postgres.sln +++ b/Jellyfin.Database.Providers.Postgres.sln @@ -1,5 +1,4 @@ - -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 diff --git a/Jellyfin.Database.Providers.Postgres/Api/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Api/PostgresController.cs deleted file mode 100644 index b91ebb4..0000000 --- a/Jellyfin.Database.Providers.Postgres/Api/PostgresController.cs +++ /dev/null @@ -1,627 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Database.Providers.Postgres.Services; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Database.Providers.Postgres.Api; - -/// Request body for starting a migration. -public sealed class StartMigrationRequest -{ - /// Gets or sets the path to the SQLite database file. - public string SqlitePath { get; set; } = string.Empty; - - /// Gets or sets the PostgreSQL connection string to migrate to. - public string ConnectionString { get; set; } = string.Empty; - - /// Gets or sets the target schema (default "public"). - public string Schema { get; set; } = "public"; - - /// Gets or sets the insert batch size (default 1000). - public int BatchSize { get; set; } = 1000; - - /// Gets or sets whether to TRUNCATE tables before inserting. - public bool Truncate { get; set; } -} - -/// Request body for testing / saving a connection string. -public sealed class ConnectionRequest -{ - /// Gets or sets the PostgreSQL connection string to test. - public string ConnectionString { get; set; } = string.Empty; -} - -/// Request body for activating PostgreSQL. -public sealed class ActivateRequest -{ - /// Gets or sets the connection string to write to database.xml. - public string ConnectionString { get; set; } = string.Empty; - - /// Gets or sets the EF command timeout in seconds. - public int CommandTimeout { get; set; } = 60; -} - -/// Request body for saving plugin configuration. -public sealed class SaveConfigRequest -{ - /// Gets or sets the connection string to save. - public string ConnectionString { get; set; } = string.Empty; - - /// Gets or sets the EF command timeout in seconds. - public int CommandTimeout { get; set; } = 60; - - /// Gets or sets the backup output directory on the server. - public string? BackupDirectory { get; set; } - - /// Gets or sets a value indicating whether backups should be zipped. - public bool BackupCompression { get; set; } = true; - - /// Gets or sets optional explicit path to pg_dump executable. - public string? PgDumpPath { get; set; } - - /// Gets or sets optional explicit path to psql executable for restore. - public string? PgRestorePath { get; set; } -} - -/// Request body for creating a backup. -public sealed class BackupRequest -{ - /// Gets or sets optional backup output directory override. - public string? OutputDirectory { get; set; } - - /// Gets or sets optional compression override. - public bool? Compress { get; set; } - - /// Gets or sets optional pg_dump path override. - public string? PgDumpPath { get; set; } -} - -/// Request body for restoring a backup. -public sealed class RestoreBackupRequest -{ - /// Gets or sets absolute path to .sql or .zip backup file. - public string BackupPath { get; set; } = string.Empty; - - /// Gets or sets optional psql path override. - public string? PgRestorePath { get; set; } - - /// - /// Gets or sets a value indicating whether existing objects should be replaced before restore. - /// Defaults to true for compatibility with plain SQL backups. - /// - public bool? ReplaceExistingObjects { get; set; } -} - -/// -/// REST API for the PostgreSQL database provider plugin. -/// Endpoints are consumed by the in-dashboard configuration page. -/// All endpoints require administrator authentication. -/// -[ApiController] -[Route("Plugins/PostgresProvider")] -[Authorize(Policy = "RequiresElevation")] -public class PostgresController : ControllerBase -{ - private readonly IApplicationPaths _appPaths; - private readonly ISystemManager _systemManager; - private readonly MigrationService _migrationService; - private readonly MaintenanceService _maintenanceService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of . - /// - public PostgresController( - IApplicationPaths appPaths, - ISystemManager systemManager, - MigrationService migrationService, - MaintenanceService maintenanceService, - ILogger logger) - { - _appPaths = appPaths; - _systemManager = systemManager; - _migrationService = migrationService; - _maintenanceService = maintenanceService; - _logger = logger; - } - - // ───────────────────────────────────────────────────────────────────────── - // Status - // ───────────────────────────────────────────────────────────────────────── - - /// Returns the plugin configuration and activation status. - [HttpGet("Status")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetStatus() - { - var isActive = PostgresPlugin.IsPostgresActive(_appPaths); - var activeConn = PostgresPlugin.ReadActivePgConnectionString(_appPaths); - var config = PostgresPlugin.Instance?.Configuration; - - var sqliteDefault = Path.Combine(_appPaths.DataPath, "jellyfin.db"); - var sqliteExists = System.IO.File.Exists(sqliteDefault); - var defaultBackupDir = Path.Combine(_appPaths.DataPath, "postgres-backups"); - - return Ok(new - { - IsPostgresActive = isActive, - ActiveConnectionString = isActive ? MaskPassword(activeConn) : null, - SqliteDefaultPath = sqliteDefault, - SqliteExists = sqliteExists, - SqliteSize = sqliteExists - ? FormatBytes(new FileInfo(sqliteDefault).Length) - : null, - SavedConnectionString = config?.ConnectionString, - SavedSchema = config?.Schema ?? "public", - SavedCommandTimeout = config?.CommandTimeout ?? 60, - SavedBackupDirectory = string.IsNullOrWhiteSpace(config?.BackupDirectory) - ? defaultBackupDir - : config!.BackupDirectory, - SavedBackupCompression = config?.BackupCompression ?? true, - SavedPgDumpPath = config?.PgDumpPath ?? string.Empty, - SavedPgRestorePath = config?.PgRestorePath ?? string.Empty, - DefaultBackupDirectory = defaultBackupDir, - MigrationState = config?.MigrationState.ToString() ?? "NotStarted", - LastMigrationError = config?.LastMigrationError, - MigrationCompletedAt = config?.MigrationCompletedAt - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Connection test - // ───────────────────────────────────────────────────────────────────────── - - /// Tests a PostgreSQL connection string. - [HttpPost("TestConnection")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> TestConnection( - [FromBody] ConnectionRequest request, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(request.ConnectionString)) - { - return BadRequest(new { Success = false, Error = "Connection string is required." }); - } - - var error = await MaintenanceService.TestConnectionAsync(request.ConnectionString, cancellationToken) - .ConfigureAwait(false); - if (error is not null) - { - return Ok(new { Success = false, Error = error }); - } - - try - { - var version = await MaintenanceService.GetServerVersionAsync(request.ConnectionString, cancellationToken) - .ConfigureAwait(false); - return Ok(new { Success = true, Version = version }); - } - catch (Exception ex) - { - return Ok(new { Success = false, Error = ex.Message }); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Save configuration - // ───────────────────────────────────────────────────────────────────────── - - /// Saves the connection string and options to the plugin configuration. - [HttpPost("SaveConfig")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult SaveConfig([FromBody] SaveConfigRequest request) - { - var plugin = PostgresPlugin.Instance; - if (plugin is null) - { - return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Plugin not loaded." }); - } - - plugin.Configuration.ConnectionString = request.ConnectionString; - plugin.Configuration.CommandTimeout = request.CommandTimeout; - if (request.BackupDirectory is not null) - { - plugin.Configuration.BackupDirectory = request.BackupDirectory; - } - - plugin.Configuration.BackupCompression = request.BackupCompression; - - if (request.PgDumpPath is not null) - { - plugin.Configuration.PgDumpPath = request.PgDumpPath; - } - - if (request.PgRestorePath is not null) - { - plugin.Configuration.PgRestorePath = request.PgRestorePath; - } - - plugin.SaveConfiguration(); - - return Ok(new { Success = true }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Migration - // ───────────────────────────────────────────────────────────────────────── - - /// Starts the SQLite → PostgreSQL migration in the background. - [HttpPost("StartMigration")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult StartMigration([FromBody] StartMigrationRequest request) - { - if (string.IsNullOrWhiteSpace(request.SqlitePath)) - { - request.SqlitePath = Path.Combine(_appPaths.DataPath, "jellyfin.db"); - } - - if (!System.IO.File.Exists(request.SqlitePath)) - { - return BadRequest(new { Error = $"SQLite file not found: {request.SqlitePath}" }); - } - - if (string.IsNullOrWhiteSpace(request.ConnectionString)) - { - return BadRequest(new { Error = "ConnectionString is required." }); - } - - var started = _migrationService.StartMigration( - sqlitePath: request.SqlitePath, - postgresConnectionString: request.ConnectionString, - schema: request.Schema, - batchSize: request.BatchSize, - truncate: request.Truncate, - applicationPaths: _appPaths); - - if (!started) - { - return Conflict(new { Error = "A migration is already in progress." }); - } - - // Save connection string to plugin config so the UI can pre-populate it - var plugin = PostgresPlugin.Instance; - if (plugin is not null) - { - plugin.Configuration.ConnectionString = request.ConnectionString; - plugin.Configuration.Schema = request.Schema; - plugin.Configuration.MigrationState = MigrationState.InProgress; - plugin.Configuration.LastMigrationError = null; - plugin.SaveConfiguration(); - } - - return Accepted(new { Status = "Migration started." }); - } - - /// Polls the current migration progress. - [HttpGet("MigrationStatus")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetMigrationStatus() - { - var progress = _migrationService.GetProgress(); - - // Persist final state to plugin config - var plugin = PostgresPlugin.Instance; - if (plugin is not null && !progress.IsRunning) - { - if (progress.IsCompleted && plugin.Configuration.MigrationState != MigrationState.Completed) - { - plugin.Configuration.MigrationState = MigrationState.Completed; - plugin.Configuration.MigratedRows = progress.MigratedRows; - plugin.Configuration.MigrationCompletedAt = DateTime.UtcNow; - plugin.Configuration.LastMigrationError = null; - plugin.SaveConfiguration(); - } - else if (progress.HasError && plugin.Configuration.MigrationState != MigrationState.Failed) - { - plugin.Configuration.MigrationState = MigrationState.Failed; - plugin.Configuration.LastMigrationError = progress.ErrorMessage; - plugin.SaveConfiguration(); - } - } - - return Ok(new - { - IsRunning = progress.IsRunning, - IsCompleted = progress.IsCompleted, - HasError = progress.HasError, - ErrorMessage = progress.ErrorMessage, - PercentComplete = progress.PercentComplete, - CurrentTable = progress.CurrentTable, - MigratedRows = progress.MigratedRows, - LogLines = progress.LogLines - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Activate / Switch to PostgreSQL - // ───────────────────────────────────────────────────────────────────────── - - /// - /// Writes database.xml to activate the PostgreSQL provider and restarts Jellyfin. - /// This is the final step after migration completes. - /// - [HttpPost("Activate")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public ActionResult Activate([FromBody] ActivateRequest request) - { - if (string.IsNullOrWhiteSpace(request.ConnectionString)) - { - return BadRequest(new { Error = "ConnectionString is required." }); - } - - _logger.LogInformation( - "Activating PostgreSQL provider. Writing database.xml and scheduling restart..."); - - try - { - PostgresPlugin.WriteDatabaseXml(_appPaths, request.ConnectionString, request.CommandTimeout); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to write database.xml"); - return StatusCode(StatusCodes.Status500InternalServerError, - new { Error = $"Failed to write database.xml: {ex.Message}" }); - } - - // Update plugin config to reflect active state - var plugin = PostgresPlugin.Instance; - if (plugin is not null) - { - plugin.Configuration.ConnectionString = request.ConnectionString; - plugin.Configuration.CommandTimeout = request.CommandTimeout; - plugin.SaveConfiguration(); - } - - // Schedule restart after response is sent - _ = Task.Run(async () => - { - await Task.Delay(500).ConfigureAwait(false); - _systemManager.Restart(); - }); - - return Accepted(new - { - Status = "PostgreSQL activated. Jellyfin is restarting..." - }); - } - - /// - /// Reverts database.xml to use SQLite and restarts Jellyfin. - /// Use this to roll back if PostgreSQL causes issues. - /// - [HttpPost("Deactivate")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public ActionResult Deactivate() - { - var configPath = Path.Combine(_appPaths.ConfigurationDirectoryPath, "database.xml"); - if (System.IO.File.Exists(configPath)) - { - System.IO.File.Delete(configPath); - _logger.LogInformation("database.xml deleted — Jellyfin will use SQLite on next start."); - } - - _ = Task.Run(async () => - { - await Task.Delay(500).ConfigureAwait(false); - _systemManager.Restart(); - }); - - return Accepted(new { Status = "Reverted to SQLite. Jellyfin is restarting..." }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Maintenance - // ───────────────────────────────────────────────────────────────────────── - - /// Returns table size statistics for the PostgreSQL database. - [HttpGet("Stats")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetStats( - [FromQuery] string? schema, - CancellationToken cancellationToken) - { - var connStr = GetActiveConnectionString(); - if (connStr is null) return NoActiveConnection(); - - var effectiveSchema = schema ?? PostgresPlugin.Instance?.Configuration.Schema ?? "public"; - var tables = await MaintenanceService.GetTableStatsAsync(connStr, effectiveSchema, cancellationToken) - .ConfigureAwait(false); - var dbSize = await MaintenanceService.GetDatabaseSizeAsync(connStr, cancellationToken) - .ConfigureAwait(false); - var connCount = await MaintenanceService.GetConnectionCountAsync(connStr, cancellationToken) - .ConfigureAwait(false); - - return Ok(new - { - DatabaseSize = dbSize, - ActiveConnections = connCount, - Tables = tables - }); - } - - /// Runs VACUUM ANALYZE on the PostgreSQL database. - [HttpPost("Vacuum")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public ActionResult Vacuum() - { - var connStr = GetActiveConnectionString(); - if (connStr is null) return NoActiveConnection(); - - _ = Task.Run(async () => - { - try - { - await _maintenanceService.VacuumAnalyzeAsync(connStr).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "VACUUM ANALYZE failed"); - } - }); - - return Accepted(new { Status = "VACUUM ANALYZE started in background." }); - } - - /// Runs REINDEX DATABASE on the PostgreSQL database. - [HttpPost("Reindex")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public ActionResult Reindex() - { - var connStr = GetActiveConnectionString(); - if (connStr is null) return NoActiveConnection(); - - _ = Task.Run(async () => - { - try - { - await _maintenanceService.ReindexAsync(connStr).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "REINDEX failed"); - } - }); - - return Accepted(new { Status = "REINDEX DATABASE started in background." }); - } - - /// Creates a PostgreSQL backup file using pg_dump. - [HttpPost("Backup")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> Backup([FromBody] BackupRequest? request, CancellationToken cancellationToken) - { - var connStr = GetActiveConnectionString(); - if (connStr is null) - { - return NoActiveConnection(); - } - - var config = PostgresPlugin.Instance?.Configuration; - var outputDir = request?.OutputDirectory; - if (string.IsNullOrWhiteSpace(outputDir)) - { - outputDir = config?.BackupDirectory; - } - - if (string.IsNullOrWhiteSpace(outputDir)) - { - outputDir = Path.Combine(_appPaths.DataPath, "postgres-backups"); - } - - var compress = request?.Compress ?? config?.BackupCompression ?? true; - var pgDumpPath = string.IsNullOrWhiteSpace(request?.PgDumpPath) - ? config?.PgDumpPath - : request!.PgDumpPath; - - try - { - var backupPath = await _maintenanceService - .CreateBackupAsync(connStr, outputDir, compress, pgDumpPath, cancellationToken) - .ConfigureAwait(false); - - return Ok(new - { - Success = true, - BackupPath = backupPath, - Compressed = compress - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Backup operation failed"); - return StatusCode(StatusCodes.Status500InternalServerError, new - { - Success = false, - Error = ex.Message - }); - } - } - - /// Restores a PostgreSQL backup file (.sql or .zip) using psql. - [HttpPost("RestoreBackup")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> RestoreBackup([FromBody] RestoreBackupRequest request, CancellationToken cancellationToken) - { - var connStr = GetActiveConnectionString(); - if (connStr is null) - { - return NoActiveConnection(); - } - - if (string.IsNullOrWhiteSpace(request.BackupPath)) - { - return BadRequest(new { Success = false, Error = "BackupPath is required." }); - } - - var config = PostgresPlugin.Instance?.Configuration; - var pgRestorePath = string.IsNullOrWhiteSpace(request.PgRestorePath) - ? config?.PgRestorePath - : request.PgRestorePath; - var replaceExistingObjects = request.ReplaceExistingObjects ?? true; - - try - { - var restoredFrom = await _maintenanceService - .RestoreBackupAsync(connStr, request.BackupPath, pgRestorePath, replaceExistingObjects, cancellationToken) - .ConfigureAwait(false); - - return Ok(new - { - Success = true, - RestoredFrom = restoredFrom - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Restore backup operation failed"); - return StatusCode(StatusCodes.Status500InternalServerError, new - { - Success = false, - Error = ex.Message - }); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - private string? GetActiveConnectionString() - { - var active = PostgresPlugin.ReadActivePgConnectionString(_appPaths); - if (!string.IsNullOrWhiteSpace(active)) return active; - var configured = PostgresPlugin.Instance?.Configuration.ConnectionString; - return string.IsNullOrWhiteSpace(configured) ? null : configured; - } - - private ObjectResult NoActiveConnection() - => StatusCode(StatusCodes.Status409Conflict, - new { Error = "PostgreSQL is not active. Activate it first or provide a connection string." }); - - /// Masks the password in a connection string so it's safe to return to the UI. - private static string? MaskPassword(string? connectionString) - { - if (connectionString is null) return null; - // Replace Password=... (up to next semicolon or end) with Password=***** - return System.Text.RegularExpressions.Regex.Replace( - connectionString, - @"(?i)(Password\s*=)[^;]+", - "$1*****"); - } - - private static string FormatBytes(long bytes) - { - if (bytes >= 1_073_741_824) return $"{bytes / 1_073_741_824.0:F1} GB"; - if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F1} MB"; - if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; - return $"{bytes} B"; - } -} diff --git a/Jellyfin.Database.Providers.Postgres/Class1.cs b/Jellyfin.Database.Providers.Postgres/Class1.cs deleted file mode 100644 index 945b624..0000000 --- a/Jellyfin.Database.Providers.Postgres/Class1.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Database.Implementations; -using Jellyfin.Database.Implementations.DbConfiguration; -using MediaBrowser.Common.Configuration; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Microsoft.Extensions.Logging; -using Npgsql; - -namespace Jellyfin.Database.Providers.Postgres; - -/// -/// Configures Jellyfin to use PostgreSQL through EF Core. -/// -[JellyfinDatabaseProviderKey("Jellyfin-Postgres")] -public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider -{ - private const string InitialMigrationId = "00000000000000_InitialCreate"; - private const string InitialMigrationProductVersion = "9.0.11"; - - private readonly IApplicationPaths? _applicationPaths; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Application paths service. Kept for DI compatibility with other providers. - /// A logger. - public PostgresDatabaseProvider(IApplicationPaths? applicationPaths, ILogger logger) - { - _applicationPaths = applicationPaths; - _logger = logger; - } - - /// - public IDbContextFactory? DbContextFactory { get; set; } - - /// - public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) - { - static T? GetOption(ICollection? customOptions, string key, Func converter, Func? defaultValue = null) - { - if (customOptions is null) - { - return defaultValue is not null ? defaultValue() : default; - } - - var item = customOptions.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); - if (item is null) - { - return defaultValue is not null ? defaultValue() : default; - } - - return converter(item.Value); - } - - var customProviderOptions = databaseConfiguration.CustomProviderOptions - ?? throw new InvalidOperationException("CustomProviderOptions must be configured for PLUGIN_PROVIDER."); - - if (string.IsNullOrWhiteSpace(customProviderOptions.ConnectionString)) - { - throw new InvalidOperationException("CustomProviderOptions.ConnectionString is required for Jellyfin-Postgres."); - } - - // Do NOT enable Npgsql.EnableLegacyTimestampBehavior. - // That switch causes Npgsql to apply the PostgreSQL session timezone when reading - // timestamptz columns, returning DateTimeKind.Local. Jellyfin then double-converts - // those values back to UTC before serializing, causing the dashboard to show UTC - // timestamps (+7 h shift on UTC-7 servers). The correct approach is strict UTC: - // store as UTC, let the UI/browser render in local time. - - var customOptions = customProviderOptions.Options; - var commandTimeout = GetOption(customOptions, "command-timeout", e => int.Parse(e, CultureInfo.InvariantCulture), () => 60); - - // Inject the host timezone into the connection string so that raw SQL clients - // (psql, pgAdmin) display timestamps in local time. Npgsql uses binary protocol - // and ignores this for its own reads, so it does NOT affect C# DateTime values. - var connBuilder = new NpgsqlConnectionStringBuilder(customProviderOptions.ConnectionString); - if (string.IsNullOrEmpty(connBuilder.Timezone)) - { - connBuilder.Timezone = GetLocalIanaTimezone(); - } - - var effectiveConnectionString = connBuilder.ConnectionString; - - options.UseNpgsql( - effectiveConnectionString, - npgsqlOptions => - { - // Must use simple assembly name, not FullName (which includes version/token). - npgsqlOptions.MigrationsAssembly(GetType().Assembly.GetName().Name!); - npgsqlOptions.CommandTimeout(commandTimeout); - // EnableRetryOnFailure is intentionally omitted: Jellyfin opens explicit user - // transactions in BaseItemRepository which are incompatible with the Npgsql - // retrying execution strategy. Transient resilience is handled by the connection - // pool and PostgreSQL's own WAL recovery. - }); - - // Self-heal migration history if schema was pre-created but __EFMigrationsHistory - // does not contain the initial migration marker. - TryRepairInitialMigrationHistory(effectiveConnectionString); - TryMitigateItemValuesIndexContention(effectiveConnectionString); - - _logger.LogWarning("PostgreSQL provider initialized for Jellyfin. Session timezone for SQL clients: {Timezone}. All DateTime values persisted as strict UTC (no legacy timestamp behavior).", connBuilder.Timezone); - } - - /// - /// Returns the host timezone as a PostgreSQL-compatible IANA name. - /// On Windows, converts from Windows timezone ID to IANA. Falls back to "UTC". - /// - private static string GetLocalIanaTimezone() - { - var localTz = TimeZoneInfo.Local; - - // On Linux/macOS the Id is already an IANA name (contains '/'). - if (localTz.Id.Contains('/', StringComparison.Ordinal)) - { - return localTz.Id; - } - - // On Windows, try to convert the Windows timezone ID to IANA. - if (TimeZoneInfo.TryConvertWindowsIdToIanaId(localTz.Id, out var ianaId) - && !string.IsNullOrEmpty(ianaId)) - { - return ianaId; - } - - return "UTC"; - } - - private void TryRepairInitialMigrationHistory(string connectionString) - { - try - { - using var conn = new NpgsqlConnection(connectionString); - conn.Open(); - - // Always ensure __EFMigrationsHistory exists before any Jellyfin migration stage - // runs. Jellyfin's PreInitialisation code migrations try to INSERT completion markers - // into this table before EF Core's CoreInitialisation stage has a chance to create it. - // On a fresh PostgreSQL schema (e.g. switching from SQLite on an existing install), - // the table won't exist yet and every PreInitialisation migration would crash with - // 42P01. Creating it here (idempotent) is safe: EF Core will use it normally. - using (var createHistoryCmd = new NpgsqlCommand(@" - CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" ( - ""MigrationId"" character varying(150) NOT NULL, - ""ProductVersion"" character varying(32) NOT NULL, - CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"") - );", conn)) - { - createHistoryCmd.ExecuteNonQuery(); - } - - // If core tables are absent this is a completely fresh schema. - // EF Core's InitialCreate migration will populate __EFMigrationsHistory itself - // during CoreInitialisation — no need to stamp it here. - using (var hasActivityCmd = new NpgsqlCommand( - "SELECT to_regclass('public.\"ActivityLogs\"') IS NOT NULL;", - conn)) - { - var hasActivityTable = Convert.ToBoolean(hasActivityCmd.ExecuteScalar(), CultureInfo.InvariantCulture); - if (!hasActivityTable) - { - var importedMigrations = TryImportMigrationHistoryFromSqlite(conn); - if (importedMigrations > 0) - { - _logger.LogWarning("PostgresDatabaseProvider: Fresh PostgreSQL schema detected. Imported {ImportedMigrations} migration history rows from SQLite.", importedMigrations); - } - else - { - _logger.LogWarning("PostgresDatabaseProvider: Fresh PostgreSQL schema detected. __EFMigrationsHistory created; EF Core will apply InitialCreate during CoreInitialisation."); - } - - return; - } - } - - // Schema already exists (pre-created outside EF Core migrations). - // Insert the InitialCreate marker so EF Core does not try to re-run it. - using (var insertCmd = new NpgsqlCommand( - "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @ver) ON CONFLICT (\"MigrationId\") DO NOTHING;", - conn)) - { - insertCmd.Parameters.AddWithValue("id", InitialMigrationId); - insertCmd.Parameters.AddWithValue("ver", InitialMigrationProductVersion); - var affected = insertCmd.ExecuteNonQuery(); - if (affected > 0) - { - _logger.LogWarning("Detected existing PostgreSQL schema without migration marker. Inserted {MigrationId} into __EFMigrationsHistory.", InitialMigrationId); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not repair PostgreSQL migration history automatically. Startup will continue."); - } - } - - private void TryMitigateItemValuesIndexContention(string connectionString) - { - try - { - using var conn = new NpgsqlConnection(connectionString); - conn.Open(); - - using (var hasTableCmd = new NpgsqlCommand( - "SELECT to_regclass('public.\"ItemValues\"') IS NOT NULL;", - conn)) - { - var hasItemValuesTable = Convert.ToBoolean(hasTableCmd.ExecuteScalar(), CultureInfo.InvariantCulture); - if (!hasItemValuesTable) - { - return; - } - } - - bool hasUniqueIndex; - using (var uniqueCheckCmd = new NpgsqlCommand(@" - SELECT COALESCE(i.indisunique, FALSE) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_index i ON i.indexrelid = c.oid - WHERE n.nspname = 'public' - AND c.relname = 'IX_ItemValues_Type_Value' - LIMIT 1;", conn)) - { - var scalar = uniqueCheckCmd.ExecuteScalar(); - hasUniqueIndex = scalar is not null && Convert.ToBoolean(scalar, CultureInfo.InvariantCulture); - } - - if (hasUniqueIndex) - { - using var dropAndCreateCmd = new NpgsqlCommand(@" - DROP INDEX IF EXISTS ""IX_ItemValues_Type_Value""; - CREATE INDEX IF NOT EXISTS ""IX_ItemValues_Type_Value"" ON ""ItemValues"" (""Type"", ""Value"");", conn); - dropAndCreateCmd.ExecuteNonQuery(); - _logger.LogWarning("Mitigation applied: converted IX_ItemValues_Type_Value from UNIQUE to non-unique to reduce runtime collisions during concurrent library refresh."); - } - else - { - using var ensureIndexCmd = new NpgsqlCommand( - "CREATE INDEX IF NOT EXISTS \"IX_ItemValues_Type_Value\" ON \"ItemValues\" (\"Type\", \"Value\");", - conn); - ensureIndexCmd.ExecuteNonQuery(); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not apply ItemValues index contention mitigation automatically. Startup will continue."); - } - } - - private int TryImportMigrationHistoryFromSqlite(NpgsqlConnection postgresConnection) - { - try - { - if (_applicationPaths is null) - { - return 0; - } - - var sqlitePath = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - if (!File.Exists(sqlitePath)) - { - return 0; - } - - var sqliteBuilder = new SqliteConnectionStringBuilder - { - DataSource = sqlitePath, - Mode = SqliteOpenMode.ReadOnly - }; - - using var sqliteConnection = new SqliteConnection(sqliteBuilder.ToString()); - sqliteConnection.Open(); - - using var selectCommand = sqliteConnection.CreateCommand(); - selectCommand.CommandText = "SELECT \"MigrationId\", \"ProductVersion\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\";"; - - using var reader = selectCommand.ExecuteReader(); - var inserted = 0; - while (reader.Read()) - { - var migrationId = reader.GetString(0); - var productVersion = reader.IsDBNull(1) ? InitialMigrationProductVersion : reader.GetString(1); - - using var insertCommand = new NpgsqlCommand( - "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @ver) ON CONFLICT (\"MigrationId\") DO NOTHING;", - postgresConnection); - insertCommand.Parameters.AddWithValue("id", migrationId); - insertCommand.Parameters.AddWithValue("ver", productVersion); - inserted += insertCommand.ExecuteNonQuery(); - } - - return inserted; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not import migration history from SQLite."); - return 0; - } - } - - /// - public Task RunScheduledOptimisation(CancellationToken cancellationToken) - { - _logger.LogInformation("PostgreSQL scheduled optimization is managed externally; no internal task executed."); - return Task.CompletedTask; - } - - /// - public Task RunShutdownTask(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - /// - public void OnModelCreating(ModelBuilder modelBuilder) - { - ArgumentNullException.ThrowIfNull(modelBuilder); - } - - /// - public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - ArgumentNullException.ThrowIfNull(configurationBuilder); - - // Npgsql 6+ requires DateTimeKind.Utc for timestamptz columns. - // Jellyfin has code paths that produce DateTimeKind.Unspecified or DateTimeKind.Local. - // These converters normalize every DateTime to Utc on both reads and writes, - // preventing InvalidCastException and ensuring correct UTC round-trips. - configurationBuilder.Properties() - .HaveConversion(); - configurationBuilder.Properties() - .HaveConversion(); - } - - private sealed class UtcDateTimeConverter : ValueConverter - { - public UtcDateTimeConverter() - : base( - // WRITE: convert Local → UTC; for Utc/Unspecified just relabel as Utc. - // SpecifyKind does NOT shift the clock — it only sets Kind — which is correct - // for Unspecified values that are already UTC (Jellyfin uses DateTime.UtcNow). - v => v.Kind == DateTimeKind.Local - ? v.ToUniversalTime() - : DateTime.SpecifyKind(v, DateTimeKind.Utc), - // READ: Npgsql v6+ already returns DateTimeKind.Utc from timestamptz. - // SpecifyKind here is belt-and-suspenders for any edge case. - v => DateTime.SpecifyKind(v, DateTimeKind.Utc)) - { - } - } - - private sealed class NullableUtcDateTimeConverter : ValueConverter - { - public NullableUtcDateTimeConverter() - : base( - v => v == null ? v : (DateTime?)(v.Value.Kind == DateTimeKind.Local - ? v.Value.ToUniversalTime() - : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)), - v => v == null ? v : (DateTime?)DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) - { - } - } - - /// - /// - /// Migration backup hooks from Jellyfin core are not used by this provider. - /// Backups are implemented in the plugin UI/tasks via pg_dump. - /// - public Task MigrationBackupFast(CancellationToken cancellationToken) - { - _logger.LogInformation("PostgreSQL provider: MigrationBackupFast is a no-op. Use plugin backup actions/tasks."); - return Task.FromResult("postgres-no-backup"); - } - - /// - public Task RestoreBackupFast(string key, CancellationToken cancellationToken) - { - _logger.LogInformation("PostgreSQL provider: RestoreBackupFast called with key '{Key}'. No-op.", key); - return Task.CompletedTask; - } - - /// - public Task DeleteBackup(string key) - { - return Task.CompletedTask; - } - - /// - public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) - { - ArgumentNullException.ThrowIfNull(dbContext); - ArgumentNullException.ThrowIfNull(tableNames); - - var list = tableNames.Where(static t => !string.IsNullOrWhiteSpace(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - if (list.Count == 0) - { - return; - } - - // Truncate with CASCADE keeps behavior close to a full data reset for selected tables. - var truncateStatements = list.Select(static table => $"TRUNCATE TABLE \"{table}\" RESTART IDENTITY CASCADE;"); - var sql = string.Join('\n', truncateStatements); - await dbContext.Database.ExecuteSqlRawAsync(sql).ConfigureAwait(false); - } -} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/ActivateRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/ActivateRequest.cs new file mode 100644 index 0000000..e9df2d6 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/ActivateRequest.cs @@ -0,0 +1,11 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for activating PostgreSQL. +public sealed class ActivateRequest +{ + /// Gets or sets the connection string to write to database.xml. + public string ConnectionString { get; set; } = string.Empty; + + /// Gets or sets the EF command timeout in seconds. + public int CommandTimeout { get; set; } = 60; +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/BackupRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/BackupRequest.cs new file mode 100644 index 0000000..4b48ca4 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/BackupRequest.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for creating a backup. +public sealed class BackupRequest +{ + /// Gets or sets optional backup output directory override. + public string? OutputDirectory { get; set; } + + /// Gets or sets optional compression override. + public bool? Compress { get; set; } + + /// Gets or sets optional PostgreSQL bin directory override for this request. + public string? PgBinPath { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/ConnectionRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/ConnectionRequest.cs new file mode 100644 index 0000000..c46fc1c --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/ConnectionRequest.cs @@ -0,0 +1,8 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for testing / saving a connection string. +public sealed class ConnectionRequest +{ + /// Gets or sets the PostgreSQL connection string to test. + public string ConnectionString { get; set; } = string.Empty; +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/RestoreBackupRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/RestoreBackupRequest.cs new file mode 100644 index 0000000..b88c5ce --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/RestoreBackupRequest.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for restoring a backup. +public sealed class RestoreBackupRequest +{ + /// Gets or sets absolute path to .sql or .zip backup file. + public string BackupPath { get; set; } = string.Empty; + + /// Gets or sets optional PostgreSQL bin directory override for this request. + public string? PgBinPath { get; set; } + + /// + /// Gets or sets a value indicating whether existing objects should be replaced before restore. + /// Defaults to true for compatibility with plain SQL backups. + /// + public bool? ReplaceExistingObjects { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs new file mode 100644 index 0000000..1c335c8 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for saving plugin configuration. +public sealed class SaveConfigRequest +{ + /// Gets or sets the connection string to save. + public string ConnectionString { get; set; } = string.Empty; + + /// Gets or sets the EF command timeout in seconds. + public int CommandTimeout { get; set; } = 60; + + /// Gets or sets the backup output directory on the server. + public string? BackupDirectory { get; set; } + + /// Gets or sets a value indicating whether backups should be zipped. + public bool BackupCompression { get; set; } = true; + + /// + /// Gets or sets the directory that contains pg_dump, psql, pg_restore, etc. + /// Leave empty to auto-detect or rely on PATH. + /// + public string? PgBinPath { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartExportToSqliteRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartExportToSqliteRequest.cs new file mode 100644 index 0000000..a111f18 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartExportToSqliteRequest.cs @@ -0,0 +1,21 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for the PostgreSQL → SQLite export. +public sealed class StartExportToSqliteRequest +{ + /// + /// Gets or sets the target SQLite .db file path. + /// Leave empty to use the auto-detected default: {DataPath}/jellyfin.db. + /// + public string? TargetSqlitePath { get; set; } + + /// + /// Gets or sets how to handle an existing file at the target path. + /// + /// overwrite — delete the existing file and recreate from the Jellyfin native schema. + /// backup — rename the existing file to .bkp before proceeding. + /// use (default) — use the existing file as-is (must already have the correct schema). + /// + /// + public string? OverwriteMode { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartMigrationRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartMigrationRequest.cs new file mode 100644 index 0000000..854f60f --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/StartMigrationRequest.cs @@ -0,0 +1,20 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// Request body for starting a migration. +public sealed class StartMigrationRequest +{ + /// Gets or sets the path to the SQLite database file. + public string SqlitePath { get; set; } = string.Empty; + + /// Gets or sets the PostgreSQL connection string to migrate to. + public string ConnectionString { get; set; } = string.Empty; + + /// Gets or sets the target schema (default "public"). + public string Schema { get; set; } = "public"; + + /// Gets or sets the insert batch size (default 1000). + public int BatchSize { get; set; } = 1000; + + /// Gets or sets a value indicating whether to TRUNCATE tables before inserting. + public bool Truncate { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs new file mode 100644 index 0000000..dc9def1 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs @@ -0,0 +1,1136 @@ +// Controller endpoints follow ASP.NET conventions — parameter doc is implicit via model bindings. +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres; +using Jellyfin.Database.Providers.Postgres.Controllers.Models; +using Jellyfin.Database.Providers.Postgres.Services; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Database.Providers.Postgres.Controllers; + +/// +/// REST API for the PostgreSQL database provider plugin. +/// Endpoints are consumed by the in-dashboard configuration page. +/// All endpoints require administrator authentication. +/// +[ApiController] +[Route("Plugins/PostgresProvider")] +[Authorize(Policy = "RequiresElevation")] +public class PostgresController : ControllerBase +{ + private const string DefaultSchema = "public"; + + private readonly IApplicationPaths _appPaths; + private readonly ISystemManager _systemManager; + private readonly MigrationService _migrationService; + private readonly MaintenanceService _maintenanceService; + private readonly InstantSearchService _instantSearch; + private readonly ExportToSqliteService _exportService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin application paths. + /// Jellyfin system manager used to restart the server. + /// Migration service instance. + /// Maintenance service instance. + /// Instant search service instance. + /// Export service instance. + /// Logger instance. + public PostgresController( + IApplicationPaths appPaths, + ISystemManager systemManager, + MigrationService migrationService, + MaintenanceService maintenanceService, + InstantSearchService instantSearch, + ExportToSqliteService exportService, + ILogger logger) + { + _appPaths = appPaths; + _systemManager = systemManager; + _migrationService = migrationService; + _maintenanceService = maintenanceService; + _instantSearch = instantSearch; + _exportService = exportService; + _logger = logger; + } + + // ───────────────────────────────────────────────────────────────────────── + // Status + // ───────────────────────────────────────────────────────────────────────── + + /// Returns the plugin configuration and activation status. + /// Plugin configuration and activation status object. + [HttpGet("Status")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetStatus() + { + var isActive = PostgresPlugin.IsPostgresActive(_appPaths); + var activeConn = PostgresPlugin.ReadActivePgConnectionString(_appPaths); + var config = PostgresPlugin.Instance?.Configuration; + + var sqliteDefault = Path.Combine(_appPaths.DataPath, "jellyfin.db"); + var sqliteExists = System.IO.File.Exists(sqliteDefault); + var defaultBackupDir = Path.Combine(_appPaths.DataPath, "postgres-backups"); + + return Ok(new + { + IsPostgresActive = isActive, + ActiveConnectionString = isActive ? MaskPassword(activeConn) : null, + SqliteDefaultPath = sqliteDefault, + SqliteExists = sqliteExists, + SqliteSize = sqliteExists + ? FormatBytes(new FileInfo(sqliteDefault).Length) + : null, + SavedConnectionString = config?.ConnectionString, + SavedSchema = config?.Schema ?? DefaultSchema, + SavedCommandTimeout = config?.CommandTimeout ?? 60, + SavedBackupDirectory = string.IsNullOrWhiteSpace(config?.BackupDirectory) + ? defaultBackupDir + : config?.BackupDirectory ?? defaultBackupDir, + SavedBackupCompression = config?.BackupCompression ?? true, + SavedPgBinPath = config?.PgBinPath ?? string.Empty, + DefaultBackupDirectory = defaultBackupDir, + MigrationState = config?.MigrationState.ToString() ?? "NotStarted", + LastMigrationError = config?.LastMigrationError, + MigrationCompletedAt = config?.MigrationCompletedAt + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connection test + // ───────────────────────────────────────────────────────────────────────── + + /// Tests a PostgreSQL connection string. + /// Request body containing the connection string to test. + /// Cancellation token. + /// Object with Success and optional Error fields. + [HttpPost("TestConnection")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TestConnection( + [FromBody] ConnectionRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.ConnectionString)) + { + return BadRequest(new { Success = false, Error = "Connection string is required." }); + } + + var error = await MaintenanceService.TestConnectionAsync(request.ConnectionString, cancellationToken) + .ConfigureAwait(false); + if (error is not null) + { + return Ok(new { Success = false, Error = error }); + } + + try + { + var version = await MaintenanceService.GetServerVersionAsync(request.ConnectionString, cancellationToken) + .ConfigureAwait(false); + return Ok(new { Success = true, Version = version }); + } + catch (Exception ex) + { + return Ok(new { Success = false, Error = ex.Message }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Save configuration + // ───────────────────────────────────────────────────────────────────────── + + /// Saves the connection string and options to the plugin configuration. + /// Request body containing connection string and plugin options. + /// Object confirming the configuration was saved. + [HttpPost("SaveConfig")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult SaveConfig([FromBody] SaveConfigRequest request) + { + var plugin = PostgresPlugin.Instance; + if (plugin is null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Plugin not loaded." }); + } + + plugin.Configuration.ConnectionString = request.ConnectionString; + plugin.Configuration.CommandTimeout = request.CommandTimeout; + if (request.BackupDirectory is not null) + { + plugin.Configuration.BackupDirectory = request.BackupDirectory; + } + + plugin.Configuration.BackupCompression = request.BackupCompression; + + if (request.PgBinPath is not null) + { + plugin.Configuration.PgBinPath = request.PgBinPath; + } + + plugin.SaveConfiguration(); + + return Ok(new { Success = true }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Migration + // ───────────────────────────────────────────────────────────────────────── + + /// Starts the SQLite → PostgreSQL migration in the background. + /// Request body with SQLite path, connection string and migration options. + /// 202 Accepted if started, 409 Conflict if already running. + [HttpPost("StartMigration")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult StartMigration([FromBody] StartMigrationRequest request) + { + if (string.IsNullOrWhiteSpace(request.SqlitePath)) + { + request.SqlitePath = Path.Combine(_appPaths.DataPath, "jellyfin.db"); + } + + // Canonicalize before checking existence to prevent path traversal (CA3003). + var safeSqlitePath = Path.GetFullPath(request.SqlitePath); + if (!FileExistsAtValidatedPath(safeSqlitePath)) + { + return BadRequest(new { Error = $"SQLite file not found: {safeSqlitePath}" }); + } + + request.SqlitePath = safeSqlitePath; + + if (string.IsNullOrWhiteSpace(request.ConnectionString)) + { + return BadRequest(new { Error = "ConnectionString is required." }); + } + + var started = _migrationService.StartMigration( + sqlitePath: request.SqlitePath, + postgresConnectionString: request.ConnectionString, + schema: request.Schema, + batchSize: request.BatchSize, + truncate: request.Truncate, + applicationPaths: _appPaths); + + if (!started) + { + return Conflict(new { Error = "A migration is already in progress." }); + } + + // Save connection string to plugin config so the UI can pre-populate it + var plugin = PostgresPlugin.Instance; + if (plugin is not null) + { + plugin.Configuration.ConnectionString = request.ConnectionString; + plugin.Configuration.Schema = request.Schema; + plugin.Configuration.MigrationState = MigrationState.InProgress; + plugin.Configuration.LastMigrationError = null; + plugin.SaveConfiguration(); + } + + return Accepted(new { Status = "Migration started." }); + } + + /// Polls the current migration progress. + /// Current migration progress object. + [HttpGet("MigrationStatus")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetMigrationStatus() + { + var progress = _migrationService.GetProgress(); + + // Persist final state to plugin config + var plugin = PostgresPlugin.Instance; + if (plugin is not null && !progress.IsRunning) + { + if (progress.IsCompleted && plugin.Configuration.MigrationState != MigrationState.Completed) + { + plugin.Configuration.MigrationState = MigrationState.Completed; + plugin.Configuration.MigratedRows = progress.MigratedRows; + plugin.Configuration.MigrationCompletedAt = DateTime.UtcNow; + plugin.Configuration.LastMigrationError = null; + plugin.SaveConfiguration(); + } + else if (progress.HasError && plugin.Configuration.MigrationState != MigrationState.Failed) + { + plugin.Configuration.MigrationState = MigrationState.Failed; + plugin.Configuration.LastMigrationError = progress.ErrorMessage; + plugin.SaveConfiguration(); + } + } + + return Ok(new + { + IsRunning = progress.IsRunning, + IsCompleted = progress.IsCompleted, + HasError = progress.HasError, + ErrorMessage = progress.ErrorMessage, + PercentComplete = progress.PercentComplete, + CurrentTable = progress.CurrentTable, + MigratedRows = progress.MigratedRows, + LogLines = progress.LogLines + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Activate / Switch to PostgreSQL + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Writes database.xml to activate the PostgreSQL provider and restarts Jellyfin. + /// This is the final step after migration completes. + /// + /// Request body containing the connection string to activate. + /// 202 Accepted if the provider was activated and server restart was requested. + [HttpPost("Activate")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public ActionResult Activate([FromBody] ActivateRequest request) + { + if (string.IsNullOrWhiteSpace(request.ConnectionString)) + { + return BadRequest(new { Error = "ConnectionString is required." }); + } + + _logger.LogInformation( + "Activating PostgreSQL provider. Writing database.xml and scheduling restart..."); + + try + { + PostgresPlugin.WriteDatabaseXml(_appPaths, request.ConnectionString, request.CommandTimeout); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write database.xml"); + return StatusCode( + StatusCodes.Status500InternalServerError, + new { Error = $"Failed to write database.xml: {ex.Message}" }); + } + + // Update plugin config to reflect active state + var plugin = PostgresPlugin.Instance; + if (plugin is not null) + { + plugin.Configuration.ConnectionString = request.ConnectionString; + plugin.Configuration.CommandTimeout = request.CommandTimeout; + plugin.SaveConfiguration(); + } + + // Schedule restart after response is sent + _ = Task.Run(async () => + { + await Task.Delay(500).ConfigureAwait(false); + _systemManager.Restart(); + }); + + return Accepted(new + { + Status = "PostgreSQL activated. Jellyfin is restarting..." + }); + } + + /// + /// Reverts database.xml to use SQLite and restarts Jellyfin. + /// Use this to roll back if PostgreSQL causes issues. + /// + /// 202 Accepted after removing the database configuration and requesting a restart. + [HttpPost("Deactivate")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public ActionResult Deactivate() + { + var configPath = Path.Combine(_appPaths.ConfigurationDirectoryPath, "database.xml"); + if (System.IO.File.Exists(configPath)) + { + System.IO.File.Delete(configPath); + _logger.LogInformation("database.xml deleted — Jellyfin will use SQLite on next start."); + + // Log the engine switch prominently so it appears in the plugin log + var sqlitePath = Services.ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath); + var sqliteSize = System.IO.File.Exists(sqlitePath) + ? $"{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..."); + } + else + { + Logging.PostgresLog.Warn("[ENGINE SWITCH] Deactivate llamado pero database.xml no existía."); + } + + _ = Task.Run(async () => + { + await Task.Delay(500).ConfigureAwait(false); + _systemManager.Restart(); + }); + + return Accepted(new { Status = "Reverted to SQLite. Jellyfin is restarting..." }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Maintenance + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Truncates all Jellyfin data tables in PostgreSQL (RESTART IDENTITY CASCADE). + /// Use before a fresh migration from SQLite to avoid PK conflicts. + /// The database schema is preserved — only data is removed. + /// + /// Cancellation token. + /// 200 OK with row counts per table before truncation. + [HttpPost("TruncateAllTables")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TruncateAllTables(CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + try + { + var result = await MaintenanceService.TruncateAllTablesAsync(connStr, cancellationToken) + .ConfigureAwait(false); + Logging.PostgresLog.Warn($"[Maintenance] TruncateAllTables: {result.TablesAffected} tablas truncadas."); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "TruncateAllTables failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new { Error = ex.Message }); + } + } + + /// Returns table size statistics for the PostgreSQL database. + /// PostgreSQL schema to query. Defaults to the configured schema. + /// Cancellation token. + /// Object with database size, active connections and per-table statistics. + [HttpGet("Stats")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetStats( + [FromQuery] string? schema, + CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var effectiveSchema = schema ?? PostgresPlugin.Instance?.Configuration.Schema ?? DefaultSchema; + var tables = await MaintenanceService.GetTableStatsAsync(connStr, effectiveSchema, cancellationToken) + .ConfigureAwait(false); + var dbSize = await MaintenanceService.GetDatabaseSizeAsync(connStr, cancellationToken) + .ConfigureAwait(false); + var connCount = await MaintenanceService.GetConnectionCountAsync(connStr, cancellationToken) + .ConfigureAwait(false); + + Logging.PostgresLog.Warn( + $"[Stats] BD: {dbSize} | Conexiones activas: {connCount} | Tablas: {tables.Count} | Schema: {effectiveSchema}"); + + return Ok(new + { + DatabaseSize = dbSize, + ActiveConnections = connCount, + Tables = tables + }); + } + + /// Runs VACUUM ANALYZE on the PostgreSQL database. + /// Cancellation token. + /// 200 OK with duration once the operation completes. + [HttpPost("Vacuum")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Vacuum(CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + await _maintenanceService.VacuumAnalyzeAsync(connStr, cancellationToken).ConfigureAwait(false); + sw.Stop(); + var msg = $"VACUUM ANALYZE completado en {sw.Elapsed.TotalSeconds:F1}s."; + return Ok(new { Status = msg, DurationSeconds = sw.Elapsed.TotalSeconds }); + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError(ex, "VACUUM ANALYZE failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new { Error = ex.Message }); + } + } + + /// Runs REINDEX DATABASE on the PostgreSQL database. + /// Cancellation token. + /// 200 OK with duration once the operation completes. + [HttpPost("Reindex")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Reindex(CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + await _maintenanceService.ReindexAsync(connStr, cancellationToken).ConfigureAwait(false); + sw.Stop(); + var msg = $"REINDEX DATABASE completado en {sw.Elapsed.TotalSeconds:F1}s."; + return Ok(new { Status = msg, DurationSeconds = sw.Elapsed.TotalSeconds }); + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError(ex, "REINDEX DATABASE failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new { Error = ex.Message }); + } + } + + /// + /// Runs 5 PostgreSQL health diagnostics in parallel and returns a structured + /// report with a severity semaphore (Ok / Warn / Error). + /// + /// Health check service instance. + /// PostgreSQL schema to inspect. Defaults to the configured schema. + /// Cancellation token. + /// Object with overall severity, findings and duration. + [HttpGet("HealthCheck")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetHealthCheck( + [FromServices] HealthCheckService healthCheckService, + [FromQuery] string? schema, + CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var effectiveSchema = schema ?? PostgresPlugin.Instance?.Configuration.Schema ?? DefaultSchema; + var result = await healthCheckService + .RunHealthCheckAsync(connStr, effectiveSchema, silent: false, cancellationToken).ConfigureAwait(false); + + return Ok(result); + } + + /// + /// Auto-repairs the issues detected by : + /// rebuilds invalid indexes (REINDEX CONCURRENTLY), vacuums bloated tables + /// and fixes out-of-sync sequences. Idempotent and safe to call repeatedly. + /// + /// Health check service instance. + /// PostgreSQL schema to inspect. Defaults to the configured schema. + /// Cancellation token. + /// Object with per-action repair results and overall success. + [HttpPost("HealthCheck/Repair")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RepairHealthCheck( + [FromServices] HealthCheckService healthCheckService, + [FromQuery] string? schema, + CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var effectiveSchema = schema ?? PostgresPlugin.Instance?.Configuration.Schema ?? DefaultSchema; + var result = await healthCheckService + .RepairAsync(connStr, effectiveSchema, cancellationToken).ConfigureAwait(false); + + return Ok(result); + } + + /// Creates a PostgreSQL backup file using pg_dump. + /// Request body with output directory and optional pg_dump path. + /// Cancellation token. + /// Object with the backup file path on success. + [HttpPost("Backup")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Backup([FromBody] BackupRequest? request, CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var config = PostgresPlugin.Instance?.Configuration; + var outputDir = request?.OutputDirectory; + if (string.IsNullOrWhiteSpace(outputDir)) + { + outputDir = config?.BackupDirectory; + } + + if (string.IsNullOrWhiteSpace(outputDir)) + { + outputDir = Path.Combine(_appPaths.DataPath, "postgres-backups"); + } + + var compress = request?.Compress ?? config?.BackupCompression ?? true; + var pgBinPath = string.IsNullOrWhiteSpace(request?.PgBinPath) + ? config?.PgBinPath + : request?.PgBinPath; + + try + { + var backupPath = await _maintenanceService + .CreateBackupAsync(connStr, outputDir, compress, pgBinPath, cancellationToken) + .ConfigureAwait(false); + + var backupSize = GetFileSizeBytes(backupPath); + return Ok(new + { + Success = true, + BackupPath = backupPath, + Compressed = compress, + SizeBytes = backupSize, + SizeFormatted = MaintenanceService.FormatBytes(backupSize) + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Backup operation failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new + { + Success = false, + Error = ex.Message + }); + } + } + + /// + /// Lists all backup files (.sql, .zip) found in the configured backup directory, newest first. + /// If is provided it overrides the saved configuration. + /// + /// Optional directory override. Defaults to the saved BackupDirectory or the data path. + /// Array of backup file metadata objects. + [HttpGet("ListBackups")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult ListBackups([FromQuery] string? directory = null) + { + var config = PostgresPlugin.Instance?.Configuration; + var dir = directory; + + if (string.IsNullOrWhiteSpace(dir)) + { + dir = config?.BackupDirectory; + } + + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(_appPaths.DataPath, "postgres-backups"); + } + + var files = MaintenanceService.ListBackups(dir); + return Ok(new + { + Directory = dir, + Files = files + }); + } + + /// Restores a PostgreSQL backup file (.sql or .zip) using psql. + /// Request body with backup file path and restore options. + /// Cancellation token. + /// Object with the restored SQL file path on success. + [HttpPost("RestoreBackup")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RestoreBackup([FromBody] RestoreBackupRequest request, CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + if (string.IsNullOrWhiteSpace(request.BackupPath)) + { + return BadRequest(new { Success = false, Error = "BackupPath is required." }); + } + + var config = PostgresPlugin.Instance?.Configuration; + var pgBinPath = string.IsNullOrWhiteSpace(request.PgBinPath) + ? config?.PgBinPath + : request.PgBinPath; + var replaceExistingObjects = request.ReplaceExistingObjects ?? true; + + try + { + var restoredFrom = await _maintenanceService + .RestoreBackupAsync(connStr, request.BackupPath, pgBinPath, replaceExistingObjects, cancellationToken) + .ConfigureAwait(false); + + return Ok(new + { + Success = true, + RestoredFrom = restoredFrom + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Restore backup operation failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new + { + Success = false, + Error = ex.Message + }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // PostgreSQL → SQLite Export + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Checks whether a file already exists at the resolved export target path. + /// The UI uses this to decide whether to show the overwrite/backup dialog. + /// + /// Optional path; defaults to the auto-detected jellyfin.db path. + /// Object with Exists boolean and the resolved Path. + [HttpGet("CheckExportTarget")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult CheckExportTarget([FromQuery] string? path) + { + var rawPath = string.IsNullOrWhiteSpace(path) + ? ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath) + : path; + var resolved = ResolveExportFilePath(rawPath); + return Ok(new { Exists = ValidatedFileExists(resolved), Path = resolved }); + } + + /// + /// Starts a PostgreSQL → SQLite export in the background. + /// Writes all data directly into a SQLite .db file. + /// Returns immediately; poll for status. + /// + /// Request body with optional target SQLite path. + /// 202 Accepted if started; 409 Conflict if already running. + [HttpPost("StartExportToSqlite")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult StartExportToSqlite([FromBody] StartExportToSqliteRequest? request) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var rawPath = string.IsNullOrWhiteSpace(request?.TargetSqlitePath) + ? ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath) + : request?.TargetSqlitePath ?? ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath); + + // Resolve final .db file path: if the user gave a directory, append jellyfin.db. + var sqlitePath = ResolveExportFilePath(rawPath); + + var mode = (request?.OverwriteMode ?? "use").ToLowerInvariant(); + + if (ValidatedFileExists(sqlitePath)) + { + if (mode == "backup") + { + var bkpPath = sqlitePath + ".bkp"; + if (ValidatedFileExists(bkpPath)) + { + ValidatedFileDelete(bkpPath); + } + + ValidatedFileMove(sqlitePath, bkpPath); + _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); + } + + // mode == "use": keep as-is + } + + // If the file still doesn't exist after backup/overwrite handling, seed the schema. + // Priority: 1) .bkp just made 2) native jellyfin.db 3) EF Core Migrations (last resort) + if (!ValidatedFileExists(sqlitePath)) + { + var seeded = TrySeedSqliteSchema(sqlitePath, _appPaths.DataPath, (ILogger)_logger); + if (!seeded) + { + return BadRequest(new + { + Error = "No se pudo crear el schema SQLite. " + + "Asegúrate de que exista un jellyfin.db válido como fuente del schema." + }); + } + } + + var started = _exportService.StartExport(connStr, sqlitePath); + if (!started) + { + return Conflict(new { Error = "An export is already in progress." }); + } + + return Accepted(new { Status = "Export started.", TargetSqlitePath = sqlitePath }); + } + + // CA3003: paths validated/canonicalized before reaching these helpers. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA3003", Justification = "Paths are canonicalized with Path.GetFullPath before use.")] + private static bool ValidatedFileExists(string path) => System.IO.File.Exists(path); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA3003", Justification = "Paths are canonicalized with Path.GetFullPath before use.")] + private static void ValidatedFileDelete(string path) => System.IO.File.Delete(path); + + /// Returns the auto-detected SQLite .db path for the current Jellyfin installation. + /// Object with the detected Path string. + [HttpGet("ExportToSqliteDefaultPath")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetExportToSqliteDefaultPath() + => Ok(new { Path = ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath) }); + + /// Returns the progress of the current or last PostgreSQL → SQLite export. + /// Current export progress object. + [HttpGet("ExportToSqliteProgress")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetExportToSqliteProgress() + { + var p = _exportService.GetProgress(); + return Ok(new + { + p.IsRunning, + p.IsCompleted, + p.HasError, + p.ErrorMessage, + p.PercentComplete, + p.CurrentTable, + p.ExportedRows, + p.TotalRows, + p.TargetSqlitePath, + p.LogLines, + }); + } + + // ───────────────────────────────────────────────────────────────────────── // Instant Search (Spotify-style) + // ─────────────────────────────────────────────────────────────────────────── + + /// + /// Lightweight instant-search endpoint designed for as-you-type UX. + /// Returns a minimal JSON array ranked by pg_trgm word similarity. + /// Bypasses EF Core entirely — direct Npgsql query, target latency < 15 ms. + /// + /// The user-typed search term. + /// Maximum results to return (1-50, default 8). + /// Optional comma-separated Jellyfin MediaType filter (e.g. "Audio,Video"). + /// Cancellation token. + /// Array of ranked search result objects. + [HttpGet("Search/Instant")] + [AllowAnonymous] // Search must work without re-auth; access is scoped to the DB user's data + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> InstantSearch( + [FromQuery] string? term, + [FromQuery] int limit = 8, + [FromQuery] string? mediaTypes = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(term)) + { + return Ok(Array.Empty()); + } + + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return StatusCode( + StatusCodes.Status503ServiceUnavailable, + new { Error = "PostgreSQL provider is not active." }); + } + + var results = await _instantSearch.SearchAsync(term, connStr, limit, mediaTypes, cancellationToken) + .ConfigureAwait(false); + + // Return compact JSON — clients use this for as-you-type suggestions + return Ok(results.ConvertAll(r => new + { + id = r.Id, + name = r.Name, + type = r.Type, + year = r.Year, + artists = r.Artists, + album = r.Album, + series = r.SeriesName, + relevance = Math.Round(r.Relevance, 3), + })); + } + + // ─────────────────────────────────────────────────────────────────────────── + // Performance Optimizations + // ─────────────────────────────────────────────────────────────────────────── + + /// + /// Returns the current state of performance optimizations: + /// whether pg_trgm is available and which GIN indexes exist. + /// + /// Cancellation token. + /// Object with GIN index list and optimization flags. + [HttpGet("OptimizationStatus")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetOptimizationStatus(CancellationToken cancellationToken) + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var config = PostgresPlugin.Instance?.Configuration; + var indexList = await MaintenanceService + .GetGinIndexStatusAsync(connStr, cancellationToken).ConfigureAwait(false); + + return Ok(new + { + TrgmAvailable = PostgresDatabaseProvider.TrgmAvailable, + GinIndexes = indexList, + SearchOptimizations = config?.EnableSearchOptimizations ?? true, + AutovacuumTuning = config?.EnableAutovacuumTuning ?? true, + PoolMin = config?.MinPoolSize ?? 4, + PoolMax = config?.MaxPoolSize ?? 100, + MaxAutoPrepare = config?.MaxAutoPrepare ?? 50, + }); + } + + /// + /// Manually triggers GIN index creation and autovacuum tuning. + /// Safe to call multiple times — all statements are idempotent. + /// The work runs in the background; this endpoint returns immediately. + /// + /// 202 Accepted — the optimization job runs in the background. + [HttpPost("ApplyOptimizations")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public ActionResult ApplyOptimizations() + { + var connStr = GetActiveConnectionString(); + if (connStr is null) + { + return NoActiveConnection(); + } + + var config = PostgresPlugin.Instance?.Configuration; + var enableSearch = config?.EnableSearchOptimizations ?? true; + var enableVacuum = config?.EnableAutovacuumTuning ?? true; + + _ = Task.Run(async () => + { + await PostgresDatabaseProvider.RunOptimizationsAsync( + connStr, enableSearch, enableVacuum, _logger, CancellationToken.None) + .ConfigureAwait(false); + }); + + return Accepted(new { Status = "Optimization job started in background." }); + } + + // ─────────────────────────────────────────────────────────────────────────── // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private string? GetActiveConnectionString() + { + var active = PostgresPlugin.ReadActivePgConnectionString(_appPaths); + if (!string.IsNullOrWhiteSpace(active)) + { + return active; + } + + var configured = PostgresPlugin.Instance?.Configuration.ConnectionString; + return string.IsNullOrWhiteSpace(configured) ? null : configured; + } + + private ObjectResult NoActiveConnection() + => StatusCode( + StatusCodes.Status409Conflict, + new { Error = "PostgreSQL is not active. Activate it first or provide a connection string." }); + + /// Masks the password in a connection string so it's safe to return to the UI. + private static string? MaskPassword(string? connectionString) + { + if (connectionString is null) + { + return null; + } + + // Replace Password=... (up to next semicolon or end) with Password=***** + return System.Text.RegularExpressions.Regex.Replace( + connectionString, + @"(?i)(Password\s*=)[^;]+", + "$1*****"); + } + + // backupPath is built internally from a trusted output directory + timestamp — not user input (CA3003). + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "backupPath is constructed by CreateBackupAsync from a server-controlled directory and timestamp, not from HTTP input.")] + private static long GetFileSizeBytes(string backupPath) + => new System.IO.FileInfo(backupPath).Length; + + private static string FormatBytes(long bytes) + { + if (bytes >= 1_073_741_824) + { + return $"{bytes / 1_073_741_824.0:F1} GB"; + } + + if (bytes >= 1_048_576) + { + return $"{bytes / 1_048_576.0:F1} MB"; + } + + if (bytes >= 1024) + { + return $"{bytes / 1024.0:F1} KB"; + } + + return $"{bytes} B"; + } + + // CA3003: path has been canonicalized via Path.GetFullPath before arriving here. + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "validatedPath has been canonicalized with Path.GetFullPath before this call. Not raw user input.")] + private static bool FileExistsAtValidatedPath(string validatedPath) + => System.IO.File.Exists(validatedPath); + + [SuppressMessage("Security", "CA3003", Justification = "Paths are canonicalized with Path.GetFullPath before use.")] + private static void ValidatedFileMove(string source, string dest) => System.IO.File.Move(source, dest); + + /// + /// Creates a fresh SQLite database at with the full + /// Jellyfin schema (including FK constraints) by running EF Core Migrations against + /// the SQLite provider. This guarantees structural correctness regardless of whether + /// an existing jellyfin.db is available. + /// + /// + /// Creates a fresh SQLite database at by running the official + /// Jellyfin SQLite provider migrations. This produces the exact same schema that Jellyfin + /// would create natively, including all tables and FK constraints. + /// + private bool TrySeedSqliteSchema(string sqlitePath, string dataPath, ILogger logger) + { + _ = dataPath; // reserved for future fallback + try + { + CreateSqliteSchemaViaEfCore(sqlitePath); + logger.LogInformation("SQLite schema created via Jellyfin SQLite provider at {Path}", sqlitePath); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create SQLite schema at {Path}", sqlitePath); + return false; + } + } + + [SuppressMessage("Security", "CA3003", Justification = "sqlitePath is canonicalized with Path.GetFullPath before this call.")] + private void CreateSqliteSchemaViaEfCore(string sqlitePath) + { + var dir = Path.GetDirectoryName(sqlitePath); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + // Locate Jellyfin.Database.Providers.Sqlite.dll. + // Candidates in priority order: next to jellyfin.exe, or next to jellyfin.dll. + var candidates = new[] + { + Path.Combine(_appPaths.ProgramSystemPath, "Jellyfin.Database.Providers.Sqlite.dll"), + Path.Combine( + Path.GetDirectoryName( + AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Jellyfin.Database.Implementations")?.Location + ?? string.Empty) ?? string.Empty, + "Jellyfin.Database.Providers.Sqlite.dll"), + }; + + var sqliteDllPath = candidates.FirstOrDefault(System.IO.File.Exists) + ?? throw new FileNotFoundException( + "Jellyfin.Database.Providers.Sqlite.dll not found. " + + $"Searched: {string.Join(", ", candidates)}"); + + _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 + .LoadFromAssemblyPath(sqliteDllPath); + + var providerType = sqliteAsm.GetType("Jellyfin.Database.Providers.Sqlite.SqliteDatabaseProvider") + ?? throw new InvalidOperationException("SqliteDatabaseProvider type not found in loaded assembly."); + + // Build ILogger via NullLoggerFactory so the generic type matches exactly. + using var loggerFactory = new NullLoggerFactory(); + var createLoggerMethod = typeof(LoggerFactoryExtensions) + .GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public) + .First(m => m.Name == "CreateLogger" && m.IsGenericMethod) + .MakeGenericMethod(providerType); + var typedLogger = createLoggerMethod.Invoke(null, new object[] { loggerFactory })!; + + var provider = (IJellyfinDatabaseProvider)Activator.CreateInstance( + providerType, + _appPaths, + typedLogger)!; + + var optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite( + $"Data Source={sqlitePath}", + opts => opts.MigrationsAssembly(sqliteAsm.GetName().Name)); + + using var ctx = new JellyfinDbContext( + optionsBuilder.Options, + NullLogger.Instance, + provider, + new NoLockBehavior(NullLogger.Instance)); + + ctx.Database.Migrate(); + } + + /// + /// Resolves the final export .db file path from a user-supplied value. + /// If the resolved path is a directory, appends jellyfin.db. + /// If it has no extension, appends .db. + /// + [SuppressMessage("Security", "CA3003", Justification = "Path is canonicalized with Path.GetFullPath; Directory.Exists operates on the resolved value, not raw user input.")] + private static string ResolveExportFilePath(string rawPath) + { + var full = Path.GetFullPath(rawPath); + + // User gave a directory path → use jellyfin.db inside it + if (Directory.Exists(full)) + { + return Path.Combine(full, "jellyfin.db"); + } + + // User gave a path without extension → add .db + if (string.IsNullOrEmpty(Path.GetExtension(full))) + { + return full + ".db"; + } + + return full; + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Migrations/00000000000000_InitialCreate.Designer.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/00000000000000_InitialCreate.Designer.cs similarity index 100% rename from Jellyfin.Database.Providers.Postgres/Migrations/00000000000000_InitialCreate.Designer.cs rename to Jellyfin.Database.Providers.Postgres/Data/Migrations/00000000000000_InitialCreate.Designer.cs diff --git a/Jellyfin.Database.Providers.Postgres/Migrations/00000000000000_InitialCreate.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/00000000000000_InitialCreate.cs similarity index 99% rename from Jellyfin.Database.Providers.Postgres/Migrations/00000000000000_InitialCreate.cs rename to Jellyfin.Database.Providers.Postgres/Data/Migrations/00000000000000_InitialCreate.cs index cc78e34..4be6fca 100644 --- a/Jellyfin.Database.Providers.Postgres/Migrations/00000000000000_InitialCreate.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/00000000000000_InitialCreate.cs @@ -1,3 +1,5 @@ +// +// EF Core generated migration — do not edit manually. using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; diff --git a/Jellyfin.Database.Providers.Postgres/Migrations/JellyfinDbContextModelSnapshot.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs similarity index 100% rename from Jellyfin.Database.Providers.Postgres/Migrations/JellyfinDbContextModelSnapshot.cs rename to Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs new file mode 100644 index 0000000..fe0ffdf --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; +using Jellyfin.Database.Providers.Postgres.Services; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using MediaBrowser.Common.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres; + +/// +/// Configures Jellyfin to use PostgreSQL through EF Core and provides +/// static optimization helpers shared across the plugin. +/// +public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider +{ + // ── Fields: readonly first (SA1214), then mutable (SA1201: fields before constructor) ─── + // 9 GIN trigram indexes for sub-15ms instant search (pg_trgm required) + // All use partial indexes (WHERE col IS NOT NULL) to avoid indexing NULL rows. + private static readonly (string Name, string Sql)[] GinIndexDefinitions = + { + ("IX_BaseItems_Name_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Name_gin_trgm" ON "BaseItems" USING gin ("Name" gin_trgm_ops) WHERE "Name" IS NOT NULL;"""), + ("IX_BaseItems_OriginalTitle_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_OriginalTitle_gin_trgm" ON "BaseItems" USING gin ("OriginalTitle" gin_trgm_ops) WHERE "OriginalTitle" IS NOT NULL;"""), + ("IX_BaseItems_Album_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Album_gin_trgm" ON "BaseItems" USING gin ("Album" gin_trgm_ops) WHERE "Album" IS NOT NULL;"""), + ("IX_BaseItems_Artists_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Artists_gin_trgm" ON "BaseItems" USING gin ("Artists" gin_trgm_ops) WHERE "Artists" IS NOT NULL;"""), + ("IX_BaseItems_AlbumArtists_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_AlbumArtists_gin_trgm" ON "BaseItems" USING gin ("AlbumArtists" gin_trgm_ops) WHERE "AlbumArtists" IS NOT NULL;"""), + ("IX_BaseItems_SeriesName_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_SeriesName_gin_trgm" ON "BaseItems" USING gin ("SeriesName" gin_trgm_ops) WHERE "SeriesName" IS NOT NULL;"""), + ("IX_ItemValues_Value_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_ItemValues_Value_gin_trgm" ON "ItemValues" USING gin ("Value" gin_trgm_ops);"""), + ("IX_ItemValues_CleanValue_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_ItemValues_CleanValue_gin_trgm" ON "ItemValues" USING gin ("CleanValue" gin_trgm_ops);"""), + ("IX_Peoples_Name_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_Peoples_Name_gin_trgm" ON "Peoples" USING gin ("Name" gin_trgm_ops) WHERE "Name" IS NOT NULL;"""), + }; + + // Composite partial indexes optimised for home-page, library-browser and resume queries. + // These match the exact column order PostgreSQL needs to satisfy ORDER BY without a sort. + private static readonly (string Name, string Sql)[] NavigationIndexDefinitions = + { + ("IX_BaseItems_ParentId_DateCreated_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_ParentId_DateCreated_Partial" ON "BaseItems" ("ParentId", "DateCreated" DESC) WHERE "IsVirtualItem" = false;"""), + ("IX_BaseItems_TopParentId_DateCreated_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_TopParentId_DateCreated_Partial" ON "BaseItems" ("TopParentId", "DateCreated" DESC) WHERE "IsVirtualItem" = false;"""), + ("IX_BaseItems_Type_TopParentId_DateCreated_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Type_TopParentId_DateCreated_Partial" ON "BaseItems" ("Type", "TopParentId", "DateCreated" DESC) WHERE "IsVirtualItem" = false;"""), + ("IX_UserData_UserId_LastPlayedDate_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_UserData_UserId_LastPlayedDate_Partial" ON "UserData" ("UserId", "LastPlayedDate" DESC) WHERE "PlaybackPositionTicks" > 0;"""), + ("IX_UserData_UserId_IsFavorite_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_UserData_UserId_IsFavorite_Partial" ON "UserData" ("UserId") WHERE "IsFavorite" = true;"""), + + // --- Additional covering indexes for frequent Jellyfin queries --- + // Cover the library browser sort by OfficialRating + DateCreated (common in All Movies view) + ("IX_BaseItems_Type_IsFolder_IsVirtualItem_OfficialRating", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Type_IsFolder_IsVirtualItem_OfficialRating" ON "BaseItems" ("Type", "IsFolder", "OfficialRating", "DateCreated" DESC) WHERE "IsVirtualItem" = false;"""), + // Cover episodes sorted by IndexNumber within a season (Next Up, Continue Watching) + ("IX_BaseItems_SeriesId_SeasonId_IndexNumber", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_SeriesId_SeasonId_IndexNumber" ON "BaseItems" ("SeriesId", "SeasonId", "IndexNumber") WHERE "IsVirtualItem" = false;"""), + // Cover UserData lookups by ItemId alone (used by playback state queries) + ("IX_UserData_ItemId_PlaybackPositionTicks_Partial", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_UserData_ItemId_PlaybackPositionTicks_Partial" ON "UserData" ("ItemId") WHERE "PlaybackPositionTicks" > 0;"""), + }; + + private static readonly string[] AutovacuumTables = { "BaseItems", "UserData", "ActivityLogs" }; + + private static readonly int[] RetryDelays = { 30, 60, 120 }; + + private readonly IApplicationPaths? _applicationPaths; + private readonly ILogger _logger; + + // Mutable static field (after readonly fields — SA1214) + private static bool _trgmAvailable; + + // Ensures the startup health check runs exactly once across all Initialise calls. + private static int _startupHealthCheckFired; + + // ── Constructor ─────────────────────────────────────────────────────────── + + /// + /// Initializes a new instance of the class. + /// + /// Application paths (may be null at design time). + /// Logger instance. + public PostgresDatabaseProvider( + IApplicationPaths? applicationPaths, + ILogger? logger) + { + _applicationPaths = applicationPaths; + _logger = logger ?? NullLogger.Instance; + + // Note: Npgsql.EnableLegacyTimestampBehavior is intentionally NOT set here. + // DateTime.Kind=Unspecified writes are handled by DateTimeKindNormalizingInterceptor + // so that the PostgreSQL server timezone is respected for reads while write + // compatibility is preserved. See DateTimeKindNormalizingInterceptor for details. + } + + // ── Properties (SA1201: after constructor) ──────────────────────────────── + + /// + /// Gets a value indicating whether the pg_trgm extension is available on the server. + /// Set during after the first successful probe. + /// + internal static bool TrgmAvailable => _trgmAvailable; + + /// + public IDbContextFactory? DbContextFactory { get; set; } + + // ── IJellyfinDatabaseProvider ───────────────────────────────────────────── + + /// + public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) + { + var connStr = (_applicationPaths is not null + ? PostgresPlugin.ReadActivePgConnectionString(_applicationPaths) + : null) + ?? PostgresPlugin.Instance?.Configuration?.ConnectionString; + + Logging.PostgresLog.Warn($"[Provider] Initialise llamado. ConnectionString resuelto: {(string.IsNullOrWhiteSpace(connStr) ? "(vacío)" : "OK")}"); + + if (string.IsNullOrWhiteSpace(connStr)) + { + throw new InvalidOperationException( + "PostgreSQL connection string is not configured. " + + "Configure it through the plugin settings before activating the PostgreSQL provider."); + } + + // 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 tunedConnStr = csb.ToString(); + + // Log the *actual* pool configuration that will be used (post-tuning). + // Mask password before logging to avoid leaking credentials. + var maskedTuned = System.Text.RegularExpressions.Regex.Replace( + tunedConnStr, + @"(?i)(Password\s*=)[^;]+", + "$1*****"); + Logging.PostgresLog.Warn( + $"[ENGINE] Conexi\u00f3n activa (tuneada): {maskedTuned}"); + Logging.PostgresLog.Warn( + $"[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!)) + .AddInterceptors( + new HomeQueryCacheInterceptor(_logger), + new UpsertConflictInterceptor(), + new DbErrorLoggingInterceptor(), + new DateTimeKindNormalizingInterceptor()); + + // 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"; + if (Interlocked.CompareExchange(ref _startupHealthCheckFired, 1, 0) == 0) + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + // Log timezone so operators can detect UTC/local mismatches. + await LogPostgresTimezoneAsync(tunedConnStr).ConfigureAwait(false); + + var svc = new HealthCheckService(NullLogger.Instance); + var result = await svc.RunHealthCheckAsync(tunedConnStr, schema, silent: true).ConfigureAwait(false); + LogHealthSummary(result); + } + catch (Exception ex) + { + Logging.PostgresLog.Error("[HealthCheck] AUTO fall\u00f3 al iniciar", ex); + } + }); + } + } + + private static async Task LogPostgresTimezoneAsync(string connectionString) + { + try + { + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync().ConfigureAwait(false); + using var cmd = new NpgsqlCommand("SHOW timezone; SHOW lc_time;", pg); + using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + 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}"); + + // Check pg_stat_statements availability. + await reader.CloseAsync().ConfigureAwait(false); + using var extCmd = new NpgsqlCommand( + "SELECT COUNT(*) FROM pg_extension WHERE extname='pg_stat_statements';", + pg); + var extCount = Convert.ToInt64( + await extCmd.ExecuteScalarAsync().ConfigureAwait(false), + System.Globalization.CultureInfo.InvariantCulture); + if (extCount == 0) + { + Logging.PostgresLog.Warn( + "[ENGINE] pg_stat_statements NO est\u00e1 activo — el check SlowQueries no retornar\u00e1 datos. " + + "Agrega 'pg_stat_statements' a shared_preload_libraries y reinicia PostgreSQL."); + } + else + { + Logging.PostgresLog.Info("[ENGINE] pg_stat_statements activo \u2713 (slow-query tracking disponible)."); + } + } + catch (Exception ex) + { + Logging.PostgresLog.Error("[ENGINE] No se pudo consultar timezone/extensiones", ex); + } + } + + private static void LogHealthSummary(HealthCheckResult result) + { + var findings = result.Findings; + var errors = findings.Count(static f => f.Severity == HealthSeverity.Error); + var warns = findings.Count(static f => f.Severity == HealthSeverity.Warn); + var info = findings.Count - errors - warns; + + Logging.PostgresLog.Warn( + $"[HealthCheck] Resumen arranque: {errors} error(es), {warns} advertencia(s), {info} informativo(s) | " + + $"Severidad: {result.OverallSeverity} | {result.DurationMs} ms"); + + foreach (var f in findings) + { + if (f.Severity == HealthSeverity.Ok) + { + continue; + } + + var det = string.IsNullOrEmpty(f.Detail) ? string.Empty : $" | {f.Detail}"; + Logging.PostgresLog.Warn($"[HealthCheck] [{f.Check}] {f.Message}{det}"); + } + } + + /// + public void OnModelCreating(ModelBuilder modelBuilder) + { + } + + /// + public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + } + + /// + public async Task RunScheduledOptimisation(CancellationToken cancellationToken) + { + var connStr = PostgresPlugin.Instance?.Configuration?.ConnectionString; + if (string.IsNullOrWhiteSpace(connStr)) + { + return; + } + + await RunOptimizationsAsync(connStr, enableSearch: true, enableVacuum: true, _logger, cancellationToken) + .ConfigureAwait(false); + } + + /// + public Task RunShutdownTask(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task MigrationBackupFast(CancellationToken cancellationToken) + => throw new NotImplementedException("Use the plugin backup task instead."); + + /// + public Task RestoreBackupFast(string key, CancellationToken cancellationToken) + => throw new NotImplementedException("Use the plugin restore task instead."); + + /// + public Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) + => throw new NotImplementedException(); + + /// + public Task DeleteBackup(string key) => throw new NotImplementedException(); + + // ── Internal optimization entry point ──────────────────────────────────── + + /// + /// Creates pg_trgm GIN indexes for near-instant search and tunes autovacuum on hot tables. + /// All statements are idempotent (CREATE INDEX CONCURRENTLY IF NOT EXISTS). + /// + /// PostgreSQL connection string. + /// Whether to create GIN trigram indexes for instant search. + /// Whether to apply aggressive autovacuum settings on hot tables. + /// Logger for progress and error reporting. + /// Cancellation token. + /// A representing the asynchronous operation. + internal static async Task RunOptimizationsAsync( + string connectionString, + bool enableSearch, + bool enableVacuum, + ILogger logger, + CancellationToken ct) + { + Logging.PostgresLog.Warn($"[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")}"); + + if (enableSearch && _trgmAvailable) + { + Logging.PostgresLog.Warn($"[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)."); + } + else if (enableSearch && !_trgmAvailable) + { + Logging.PostgresLog.Warn("[Optimization] \u00cdndices GIN omitidos \u2014 pg_trgm no disponible. Ejecuta: CREATE EXTENSION pg_trgm;"); + } + + // 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..."); + await ApplyNavigationIndexesAsync(conn, logger, ct).ConfigureAwait(false); + Logging.PostgresLog.Warn("[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)."); + + if (enableVacuum) + { + Logging.PostgresLog.Warn("[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)}"); + } + + // 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."); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static async Task CheckTrgmAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + // Hardcoded SQL literal — no user input (CA2100) + const string sql = "SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_trgm';"; + try + { + using var cmd = new NpgsqlCommand(sql, conn); + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + var available = Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture) > 0; + if (!available) + { + logger.LogWarning("pg_trgm extension not found. Run: CREATE EXTENSION pg_trgm;"); + } + + return available; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not probe pg_trgm availability."); + return false; + } + } + + private static async Task ApplySearchIndexesAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + foreach (var (name, sql) in GinIndexDefinitions) + { + await TryExecuteIndexWithRetryAsync(sql, name, conn, logger, ct).ConfigureAwait(false); + } + } + + private static async Task ApplyNavigationIndexesAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + foreach (var (name, sql) in NavigationIndexDefinitions) + { + await TryExecuteIndexWithRetryAsync(sql, name, conn, logger, ct).ConfigureAwait(false); + } + + logger.LogInformation("PostgreSQL navigation indexes applied. Home page, library browser and resume queries optimized."); + } + + [SuppressMessage("Security", "CA2100", Justification = "work_mem value is a hardcoded constant, not user input.")] + private static async Task ApplyServerMemoryTuningAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + // Tune the query planner and sort memory for a home-media-server workload. + // work_mem controls per-sort-operation memory; raising it avoids disk spills + // on ORDER BY DateCreated DESC, ORDER BY RANDOM(), etc. + // This is a session-level SET — no server restart required. + const string sql = "SET work_mem = '16MB';"; + try + { + using var cmd = new NpgsqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + logger.LogInformation("PostgreSQL session memory tuning applied (work_mem=16MB)."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not apply server memory tuning — skipping."); + } + } + + private static async Task ApplyAutovacuumTuningAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + // AutovacuumTables is a hardcoded static array — not user input (CA2100). + foreach (var table in AutovacuumTables) + { + try + { + using var cmd = CreateAutovacuumCommand(conn, table); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not apply autovacuum tuning for {Table}.", table); + } + } + } + + private static async Task TryExecuteIndexWithRetryAsync( + string sql, + string indexName, + NpgsqlConnection connection, + ILogger logger, + CancellationToken ct) + { + for (var attempt = 0; attempt <= RetryDelays.Length; attempt++) + { + try + { + // 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}"); + return; + } + catch (PostgresException pgEx) when (pgEx.SqlState == "40P01" && attempt < RetryDelays.Length) + { + var delay = RetryDelays[attempt]; + logger.LogWarning( + pgEx, + "Deadlock creating {IndexName} (attempt {Attempt}). Retrying in {Delay}s.", + indexName, + attempt + 1, + delay); + await Task.Delay(TimeSpan.FromSeconds(delay), ct).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not create index {IndexName}. Skipping.", indexName); + return; + } + } + } + + // ── Command factory methods (CA2100) ────────────────────────────────────── + + // table comes from AutovacuumTables (private static readonly array), not user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table comes from AutovacuumTables private static readonly array, not user input. Identifier is quoted.")] + private static NpgsqlCommand CreateAutovacuumCommand(NpgsqlConnection conn, string table) + { + var sql = string.Concat( + "ALTER TABLE \"", + table.Replace("\"", "\"\"", StringComparison.Ordinal), + "\" SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_analyze_scale_factor = 0.005);"); + return new NpgsqlCommand(sql, conn); + } + + // sql comes from GinIndexDefinitions (private static readonly array), not user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "sql comes from GinIndexDefinitions private static readonly array, not user input.")] + private static NpgsqlCommand CreateIndexCommand(NpgsqlConnection conn, string sql) + => new NpgsqlCommand(sql, conn) { CommandTimeout = 0 }; + + /// + /// Tries to activate pg_stat_statements so slow queries are visible in the + /// Health Check and in tools like pgAdmin. Silently skips when the extension requires + /// a server restart (needs shared_preload_libraries) or the user lacks privileges. + /// + private static async Task TryActivateStatStatementsAsync(NpgsqlConnection conn, ILogger logger, CancellationToken ct) + { + try + { + // Check if already active + using var checkCmd = new NpgsqlCommand( + "SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_stat_statements';", conn); + var count = Convert.ToInt64( + await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + System.Globalization.CultureInfo.InvariantCulture); + + if (count > 0) + { + Logging.PostgresLog.Info("[Optimization] pg_stat_statements: ya activo \u2713 (slow-query tracking disponible)."); + return; + } + + // Try to create it — may fail if not in shared_preload_libraries + 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)."); + } + catch (PostgresException pgEx) when (pgEx.SqlState is "55P02" or "42501") + { + // 55P02 = extension requires restart, 42501 = insufficient privileges + Logging.PostgresLog.Warn( + $"[Optimization] pg_stat_statements no pudo activarse autom\u00e1ticamente ({pgEx.MessageText}). " + + "Para activarlo manualmente: agrega 'pg_stat_statements' a shared_preload_libraries en postgresql.conf y reinicia PostgreSQL."); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not activate pg_stat_statements — skipping."); + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/PostgresDesignTimeDbFactory.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs similarity index 88% rename from Jellyfin.Database.Providers.Postgres/PostgresDesignTimeDbFactory.cs rename to Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs index 91ee786..5da0759 100644 --- a/Jellyfin.Database.Providers.Postgres/PostgresDesignTimeDbFactory.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs @@ -14,8 +14,8 @@ internal sealed class PostgresDesignTimeDbFactory : IDesignTimeDbContextFactory< { public JellyfinDbContext CreateDbContext(string[] args) { - // Legacy timestamp behavior must match runtime configuration. - AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + // Note: Npgsql.EnableLegacyTimestampBehavior is NOT set — DateTime.Kind=Unspecified + // is handled at runtime by DateTimeKindNormalizingInterceptor, not by the global switch. // A real connection is not required for migration generation — EF Core // only needs to resolve the model from the DbContext. diff --git a/Jellyfin.Database.Providers.Postgres/JellyTrendContract/IRecommendationQueryProvider.cs b/Jellyfin.Database.Providers.Postgres/JellyTrendContract/IRecommendationQueryProvider.cs new file mode 100644 index 0000000..1aa7e34 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/JellyTrendContract/IRecommendationQueryProvider.cs @@ -0,0 +1,123 @@ +// ────────────────────────────────────────────────────────────────────────────── +// BUILD-TIME CONTRACT — do NOT modify. +// +// This file is a compile-time mirror of the public API declared in the +// JellyTrend plugin (Jellyfin.Plugin.JellyTrend.Api.IRecommendationQueryProvider). +// It exists so this project can implement the interface without referencing the +// JellyTrend DLL at build time (no HintPath, no NuGet dependency). +// +// Rules: +// • Namespace, type names and method signatures MUST stay identical to the +// originals in JellyTrend — any drift will cause a TypeLoadException at runtime. +// • When the JellyTrend contract changes, update this file to match. +// • This file ships in the Postgres plugin DLL but is NEVER loaded at runtime +// for its type identity — Jellyfin loads the real types from the JellyTrend DLL. +// ────────────────────────────────────────────────────────────────────────────── +using System; +using System.Collections.Generic; + +namespace Jellyfin.Plugin.JellyTrend.Api; + +/// +/// Optional database-provider-specific query backend for the recommendation engine. +/// When registered in the Jellyfin DI container (e.g. by the PostgreSQL Database Provider +/// plugin), the recommendation engine delegates its library queries to this interface +/// instead of using ILibraryManager.GetItemList. +/// +/// +/// Implementations must never throw for recoverable errors — return an empty list instead. +/// If the provider is not registered, the engine falls back to ILibraryManager +/// transparently so JellyTrend works on SQLite without changes. +/// +public interface IRecommendationQueryProvider +{ + /// + /// Returns movies the user has played, up to items. + /// + /// The user whose play history to query. + /// Maximum number of items to return. + /// Lightweight projections of played movies. + IReadOnlyList GetPlayedMovies(Guid userId, int limit); + + /// + /// Returns movies the user has started but not finished (resume candidates). + /// + /// The user whose resume queue to query. + /// Maximum number of items to return. + /// Lightweight projections of in-progress movies. + IReadOnlyList GetResumableMovies(Guid userId, int limit); + + /// + /// Returns unwatched movie candidates that match the given genres. + /// + /// User whose played status is checked. + /// Genres to filter by (OR logic — any match qualifies). + /// Library root ids to scope the query. + /// Maximum number of items to return. + /// Lightweight projections of matching unwatched movies. + IReadOnlyList GetUnwatchedMoviesByGenres( + Guid userId, + IReadOnlyList genres, + IReadOnlyList topParentIds, + int limit); + + /// + /// Returns unwatched movie candidates associated with the given person ids. + /// + /// User whose played status is checked. + /// Person ids to filter by (OR logic). + /// Library root ids to scope the query. + /// Maximum number of items to return. + /// Lightweight projections of matching unwatched movies. + IReadOnlyList GetUnwatchedMoviesByPersons( + Guid userId, + IReadOnlyList personIds, + IReadOnlyList topParentIds, + int limit); + + /// + /// Returns unwatched movie candidates that carry the given tags. + /// + /// User whose played status is checked. + /// Tags to filter by (OR logic). + /// Library root ids to scope the query. + /// Maximum number of items to return. + /// Lightweight projections of matching unwatched movies. + IReadOnlyList GetUnwatchedMoviesByTags( + Guid userId, + IReadOnlyList tags, + IReadOnlyList topParentIds, + int limit); + + /// + /// Returns a random sample of unwatched movies for cold-start users. + /// + /// User whose played status is checked. + /// Library root ids to scope the query. + /// Maximum number of items to return. + /// Lightweight projections of random unwatched movies. + IReadOnlyList GetRandomUnwatchedMovies( + Guid userId, + IReadOnlyList topParentIds, + int limit); +} + +/// +/// Minimal projection of a library item used by . +/// Contains only the fields needed by the recommendation engine for scoring. +/// +/// Jellyfin item GUID. +/// TMDB provider id, or null if not available. +/// Genre names. +/// Tag names. +/// Studio names. +/// Community rating (0–10), or null. +/// Premiere date in UTC, or null. +public sealed record RecommendationItem( + Guid Id, + string? TmdbId, + IReadOnlyList Genres, + IReadOnlyList Tags, + IReadOnlyList Studios, + float? CommunityRating, + DateTime? PremiereDate); diff --git a/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj b/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj index 5355416..68960fa 100644 --- a/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj +++ b/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj @@ -1,50 +1,66 @@  - net9.0 Jellyfin.Database.Providers.Postgres - Jellyfin.Database.Providers.Postgres enable - enable - true - 1.0.0.0 true - false - true + true + enable + AllEnabledByDefault + ../jellyfin.ruleset + + 10.11.10 - - - - - - - + + runtime - + + runtime - - + runtime + - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + runtime + + + + + + + + + + + + - + diff --git a/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs b/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs new file mode 100644 index 0000000..13e28c9 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs @@ -0,0 +1,271 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace Jellyfin.Database.Providers.Postgres.Logging; + +/// +/// Dedicated file logger for the PostgreSQL Database Provider plugin. +/// Writes timestamped lines to a daily-rotating log file +/// (Postgres-yyyy-MM-dd.log) inside Jellyfin's log directory. +/// Files older than days are deleted automatically. +/// Never throws — logging failures are silently swallowed to protect the host. +/// +internal static class PostgresLog +{ + /// Number of days to keep log files before automatic deletion. + internal const int RetentionDays = 14; + + private static readonly object Lock = new(); + + // Default path — overridden at plugin startup via SetLogDirectory(). + private static string _logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "jellyfin", + "log"); + + private static DateTimeOffset _lastWrite = DateTimeOffset.MinValue; + + // ── Public API ───────────────────────────────────────────────────────── + + /// Gets the absolute path of today's log file. + public static string CurrentLogPath => + Path.Combine(_logDir, $"Postgres-{DateTimeOffset.Now:yyyy-MM-dd}.log"); + + /// + /// Sets the directory where log files are written, trying each candidate + /// in priority order until a writable path is found. + /// + /// Directory paths in priority order. + public static void SetLogDirectory(params string[] candidates) + { + foreach (var dir in candidates) + { + if (string.IsNullOrWhiteSpace(dir)) + { + continue; + } + + try + { + Directory.CreateDirectory(dir); + _logDir = dir; + return; + } + catch + { + // Try next candidate. + } + } + } + + /// Appends a timestamped line at the given level. + /// Short level label, e.g. "INFO ", "WARN ". + /// The message to write. + public static void Write(string level, string message) + { + try + { + var now = DateTimeOffset.Now; + var line = string.Format( + CultureInfo.InvariantCulture, + "[{0:yyyy-MM-dd HH:mm:ss.fff}] [{1}] {2}{3}", + now, + level, + message, + Environment.NewLine); + + lock (Lock) + { + EnsureDirectory(); + + if (now.Date != _lastWrite.Date) + { + PurgeOldLogs(); + _lastWrite = now; + } + + File.AppendAllText(CurrentLogPath, line); + } + } + catch + { + // Never crash Jellyfin due to a logging failure. + } + } + + /// Writes an informational message. + /// The message to write. + public static void Info(string msg) => Write("INFO ", msg); + + /// Writes a debug-level message. + /// The message to write. + public static void Debug(string msg) => Write("DEBUG", msg); + + /// Writes a warning message. + /// The message to write. + public static void Warn(string msg) => Write("WARN ", msg); + + /// Writes an error message. + /// The message to write. + public static void Error(string msg) => Write("ERROR", msg); + + /// 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)); + + // ── Private helpers ──────────────────────────────────────────────────── + + private static void EnsureDirectory() + { + try + { + if (!Directory.Exists(_logDir)) + { + Directory.CreateDirectory(_logDir); + } + } + catch + { + // Best effort. + } + } + + private static void PurgeOldLogs() + { + try + { + var cutoff = DateTimeOffset.Now.Date.AddDays(-RetentionDays); + foreach (var file in Directory.GetFiles(_logDir, "Postgres-*.log")) + { + var name = Path.GetFileNameWithoutExtension(file); + var datePart = name.Length >= 10 ? name[^10..] : null; + + if (datePart is not null + && DateTimeOffset.TryParseExact( + datePart, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var fileDate) + && fileDate.Date < cutoff) + { + File.Delete(file); + } + } + } + catch + { + // Best effort. + } + } + + // ── TaskScope ────────────────────────────────────────────────────────── + + /// + /// Scope for a scheduled task or long-running operation. + /// Writes a DEBUG line on start and a single INFO or ERROR summary on completion. + /// Intermediate messages should use to keep logs readable. + /// + internal sealed class TaskScope : IDisposable + { + private readonly string _task; + private readonly Stopwatch _stopwatch; + private bool _done; + + private TaskScope(string task) + { + _task = task; + _stopwatch = Stopwatch.StartNew(); + Debug(string.Format(CultureInfo.InvariantCulture, "[{0}] Iniciando.", task)); + } + + /// + /// Starts a new task execution scope. + /// + /// Human-readable task name. + /// The scope; dispose to close without an explicit outcome. + public static TaskScope Begin(string task) => new(task); + + /// Marks the task as completed with a one-line summary. + /// Short outcome description. + public void Complete(string summary) + { + if (_done) + { + return; + } + + _done = true; + Info(string.Format( + CultureInfo.InvariantCulture, + "[{0}] Completado en {1} ms: {2}", + _task, + _stopwatch.ElapsedMilliseconds, + summary)); + } + + /// Marks the task as failed. + /// The exception that caused the failure. + /// Short failure description. + public void Fail(Exception exception, string summary) + { + if (_done) + { + return; + } + + _done = true; + Error( + string.Format( + CultureInfo.InvariantCulture, + "[{0}] Falló tras {1} ms: {2}", + _task, + _stopwatch.ElapsedMilliseconds, + summary), + exception); + } + + /// Marks the task as cancelled (normal condition). + /// Short description. + public void Cancel(string summary) + { + if (_done) + { + return; + } + + _done = true; + Info(string.Format( + CultureInfo.InvariantCulture, + "[{0}] Cancelado tras {1} ms: {2}", + _task, + _stopwatch.ElapsedMilliseconds, + summary)); + } + + /// + public void Dispose() + { + if (!_done) + { + _done = true; + Debug(string.Format( + CultureInfo.InvariantCulture, + "[{0}] Finalizado tras {1} ms.", + _task, + _stopwatch.ElapsedMilliseconds)); + } + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/MigrationState.cs b/Jellyfin.Database.Providers.Postgres/MigrationState.cs new file mode 100644 index 0000000..d4724d2 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/MigrationState.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Database.Providers.Postgres; + +/// +/// Represents the state of the SQLite → PostgreSQL migration. +/// +public enum MigrationState +{ + /// Migration has not been started yet. + NotStarted, + + /// Migration is currently in progress. + InProgress, + + /// Migration completed successfully. + Completed, + + /// Migration failed. See LastMigrationError for details. + Failed, +} diff --git a/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs b/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs index 78bc2d8..c880bc6 100644 --- a/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs +++ b/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs @@ -55,32 +55,45 @@ public class PluginConfiguration : BasePluginConfiguration public bool BackupCompression { get; set; } = true; /// - /// Gets or sets an optional full path to pg_dump executable. - /// Leave empty to use pg_dump from PATH. + /// Gets or sets the directory that contains the PostgreSQL client binaries + /// (pg_dump, psql, pg_restore, etc.). + /// Example on Windows: C:\Program Files\PostgreSQL\17\bin + /// Example on Linux: /usr/lib/postgresql/17/bin + /// Leave empty to auto-detect (searches well-known paths) or rely on PATH. /// - public string PgDumpPath { get; set; } = string.Empty; + public string PgBinPath { get; set; } = string.Empty; + + // ── Connection Pool Tuning ────────────────────────────────────────────── /// - /// Gets or sets an optional full path to psql executable for restore operations. - /// Leave empty to use psql from PATH. + /// Gets or sets the minimum number of connections kept alive in the pool. + /// Keeping a few connections warm avoids cold-start latency on bursts. /// - public string PgRestorePath { get; set; } = string.Empty; -} + public int MinPoolSize { get; set; } = 4; -/// -/// Represents the state of the SQLite → PostgreSQL migration. -/// -public enum MigrationState -{ - /// Migration has not been started yet. - NotStarted, + /// + /// Gets or sets the maximum number of connections in the pool. + /// + public int MaxPoolSize { get; set; } = 100; - /// Migration is currently in progress. - InProgress, + /// + /// Gets or sets the maximum number of statements Npgsql will auto-prepare + /// server-side (caches query plans). 0 disables the feature. + /// + public int MaxAutoPrepare { get; set; } = 50; - /// Migration completed successfully. - Completed, + // ── Search & Index Optimizations ───────────────────────────────────── - /// Migration failed. See LastMigrationError for details. - Failed + /// + /// Gets or sets a value indicating whether to create pg_trgm GIN indexes at startup + /// for near-instant full-text search. Requires pg_trgm extension and CREATE EXTENSION + /// privilege. Disable if your DB user lacks that privilege. + /// + public bool EnableSearchOptimizations { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to apply aggressive autovacuum settings + /// on high-churn tables (UserData, ActivityLogs) to prevent bloat. + /// + public bool EnableAutovacuumTuning { get; set; } = true; } diff --git a/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs b/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs index ce2a040..4c9722a 100644 --- a/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs +++ b/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; +using Jellyfin.Database.Providers.Postgres.Logging; +using Jellyfin.Database.Providers.Postgres.Services; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; @@ -19,10 +21,11 @@ namespace Jellyfin.Database.Providers.Postgres; /// public class PostgresPlugin : BasePlugin, IHasWebPages { - private static PostgresPlugin? _instance; private const string PluginDisplayName = "PostgreSQL Database Provider"; private const string PluginAssemblyName = "Jellyfin.Database.Providers.Postgres.dll"; + private static PostgresPlugin? _instance; + /// /// Initializes a new instance of the class. /// @@ -32,6 +35,31 @@ public PostgresPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSeri : base(applicationPaths, xmlSerializer) { _instance = this; + PostgresLog.SetLogDirectory(applicationPaths.LogDirectoryPath); + PostgresLog.Info($"PostgreSQL Database Provider v{Version} loaded. Log directory: {applicationPaths.LogDirectoryPath}"); + + // Log the active database engine so the plugin log always shows which mode Jellyfin is in + var isPostgresActive = IsPostgresActive(applicationPaths); + if (isPostgresActive) + { + var activeConnStr = ReadActivePgConnectionString(applicationPaths); + // Mask password for safety + var maskedConn = activeConnStr is not null + ? System.Text.RegularExpressions.Regex.Replace( + activeConnStr, + @"(?i)(Password\s*=)[^;]+", + "$1*****") + : "(unknown)"; + PostgresLog.Warn($"[ENGINE] Modo activo: PostgreSQL. Connection: {maskedConn}"); + } + else + { + var sqlitePath = System.IO.Path.Combine(applicationPaths.DataPath, "jellyfin.db"); + 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})"); + } } /// Gets the singleton instance of the plugin. @@ -49,6 +77,7 @@ public PostgresPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSeri /// /// Returns the embedded HTML configuration page served by the Jellyfin dashboard. /// + /// A collection of plugin page descriptors. public IEnumerable GetPages() { yield return new PluginPageInfo @@ -74,7 +103,7 @@ 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 = SecurityElement.Escape(connectionString) ?? connectionString; + var escapedConnStr = SecurityElementHelper.Escape(connectionString) ?? connectionString; var xml = new StringBuilder(); xml.AppendLine(""); @@ -108,6 +137,8 @@ public static void WriteDatabaseXml(IApplicationPaths applicationPaths, string c /// Reads the current database.xml and returns the connection string if the /// PostgreSQL provider is currently active, otherwise . /// + /// Jellyfin application paths. + /// The active PostgreSQL connection string, or if not active. public static string? ReadActivePgConnectionString(IApplicationPaths applicationPaths) { var filePath = Path.Combine(applicationPaths.ConfigurationDirectoryPath, "database.xml"); @@ -146,26 +177,8 @@ public static void WriteDatabaseXml(IApplicationPaths applicationPaths, string c /// Returns if database.xml currently points to the /// PostgreSQL provider. /// + /// Jellyfin application paths. + /// if PostgreSQL is the active provider. public static bool IsPostgresActive(IApplicationPaths applicationPaths) => ReadActivePgConnectionString(applicationPaths) is not null; } - -// Shim for System.Security.SecurityElement.Escape which is not in netstandard but IS in net9.0. -// We use a simple manual approach to keep the dependency surface small. -file static class SecurityElement -{ - public static string? Escape(string? text) - { - if (text is null) - { - return null; - } - - return text - .Replace("&", "&", StringComparison.Ordinal) - .Replace("<", "<", StringComparison.Ordinal) - .Replace(">", ">", StringComparison.Ordinal) - .Replace("\"", """, StringComparison.Ordinal) - .Replace("'", "'", StringComparison.Ordinal); - } -} diff --git a/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs b/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs index 0069bce..34a329e 100644 --- a/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs +++ b/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs @@ -1,9 +1,12 @@ +using System; using Jellyfin.Database.Providers.Postgres.Services; using Jellyfin.Database.Providers.Postgres.Tasks; +using Jellyfin.Plugin.JellyTrend.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Jellyfin.Database.Providers.Postgres; @@ -20,8 +23,47 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic { serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + // JellyTrend integration — register only when JellyTrend plugin is also installed. + // The interface type is known at compile time (JellyTrendContract mirror), but at + // runtime we resolve it from the JellyTrend assembly so DI wires up correctly. + // If JellyTrend is absent the AppDomain lookup returns null and we skip silently. + TryRegisterJellyTrendProvider(serviceCollection); + } + + private static void TryRegisterJellyTrendProvider(IServiceCollection serviceCollection) + { + try + { + // Resolve the real interface type from the loaded JellyTrend assembly. + // Our local mirror (JellyTrendContract/) has the same fully-qualified name, + // so we use it only as a compile-time anchor — DI must use the JellyTrend type. + var interfaceType = Type.GetType( + "Jellyfin.Plugin.JellyTrend.Api.IRecommendationQueryProvider, Jellyfin.Plugin.JellyTrend", + throwOnError: false); + + if (interfaceType is null) + { + return; + } + + serviceCollection.AddSingleton( + interfaceType, + sp => new PostgresRecommendationQueryProvider( + sp.GetRequiredService>())); + + Logging.PostgresLog.Info("[JellyTrend] IRecommendationQueryProvider registrado. Las recomendaciones usarán SQL optimizado de PostgreSQL."); + } + catch (Exception ex) + { + Logging.PostgresLog.Warn($"[JellyTrend] No se pudo registrar IRecommendationQueryProvider: {ex.Message}"); + } } } diff --git a/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs new file mode 100644 index 0000000..6a4601d --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs @@ -0,0 +1,121 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// EF Core command interceptor that normalises parameters with +/// to before +/// each write command is sent to PostgreSQL. +/// +/// +/// +/// Npgsql 9.x rejects DateTime values whose Kind is +/// when the target column is +/// timestamp with time zone. Jellyfin's external metadata providers +/// (TVDB, TMDB, …) produce air-date and premiere-date values without an explicit +/// DateTimeKind, causing DbUpdateException during library scans. +/// +/// +/// The previous workaround was to set the AppContext switch +/// Npgsql.EnableLegacyTimestampBehavior = true, but that switch also +/// disables the server-side timezone conversion for reads, forcing every +/// timestamp to be returned as UTC regardless of the PostgreSQL server's +/// timezone setting. +/// +/// +/// This interceptor applies the minimal fix — it only affects write parameters with +/// Kind == Unspecified — so the server timezone is honoured for read queries +/// while the DbUpdateException on writes is fully prevented. +/// +/// +public sealed class DateTimeKindNormalizingInterceptor : DbCommandInterceptor +{ + /// + public override InterceptionResult NonQueryExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + NormalizeParameters(command); + return result; + } + + /// + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + NormalizeParameters(command); + return ValueTask.FromResult(result); + } + + /// + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + NormalizeParameters(command); + return result; + } + + /// + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + NormalizeParameters(command); + return ValueTask.FromResult(result); + } + + /// + public override InterceptionResult ScalarExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + NormalizeParameters(command); + return result; + } + + /// + public override ValueTask> ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + NormalizeParameters(command); + return ValueTask.FromResult(result); + } + + // ── Implementation ──────────────────────────────────────────────────────── + + private static void NormalizeParameters(DbCommand command) + { + foreach (DbParameter param in command.Parameters) + { + if (param.DbType is not (DbType.DateTime or DbType.DateTime2 or DbType.DateTimeOffset)) + { + continue; + } + + if (param.Value is DateTime dt && dt.Kind == DateTimeKind.Unspecified) + { + // Treat Unspecified as UTC — matches what Jellyfin's SQLite path + // assumes and what the legacy Npgsql switch used to do, but only + // for write parameters, leaving read-side timezone handling intact. + param.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc); + } + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs new file mode 100644 index 0000000..7c753ad --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs @@ -0,0 +1,95 @@ +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// EF Core command interceptor that writes every database-level error to the plugin +/// log file so operators do not need to dig through Jellyfin''s main log to find +/// PostgreSQL problems (e.g. 23505 duplicate key, 26000 missing prepared statement, +/// XX001 page corruption, slow queries). +/// +/// +/// Only error-level events are logged; successful commands are left untouched so +/// that the plugin log stays readable and not flooded with routine query noise. +/// +public sealed class DbErrorLoggingInterceptor : DbCommandInterceptor +{ + // 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 = + [ + "02000", // no_data + "02001", // no_additional_dynamic_result_sets_returned + ]; + + /// + public override void CommandFailed(DbCommand command, CommandErrorEventData eventData) + => LogCommandError(command, eventData.Exception); + + /// + public override Task CommandFailedAsync( + DbCommand command, + CommandErrorEventData eventData, + CancellationToken cancellationToken = default) + { + LogCommandError(command, eventData.Exception); + return Task.CompletedTask; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static void LogCommandError(DbCommand command, Exception? exception) + { + if (exception is null) + { + return; + } + + // Skip benign non-error states. + if (exception is PostgresException pgEx) + { + if (SilentSqlStates.Any(state => string.Equals(pgEx.SqlState, state, StringComparison.Ordinal))) + { + return; + } + + var severity = pgEx.Severity?.ToUpperInvariant(); + var label = severity switch + { + "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}"); + } + else + { + var sqlPreview = Truncate(command.CommandText, 200); + PostgresLog.Error( + $"[DB] Error de base de datos: {exception.GetType().Name}: {exception.Message} | SQL: {sqlPreview}"); + } + } + + private static string Truncate(string? value, int maxLength) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value.Length <= maxLength + ? value + : string.Concat(value.AsSpan(0, maxLength), "\u2026"); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/ExportHelpers.cs b/Jellyfin.Database.Providers.Postgres/Services/ExportHelpers.cs new file mode 100644 index 0000000..08920a4 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/ExportHelpers.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Static helpers for : +/// PostgreSQL catalog queries and SQLite DDL utilities. +/// +internal static class ExportHelpers +{ + // ── PostgreSQL catalog queries ──────────────────────────────────────────── + + /// Lists all user tables in the public schema. + /// Open PostgreSQL connection. + /// Cancellation token. + /// List of table names in the public schema. + internal static async Task> GetUserTablesAsync(NpgsqlConnection conn, CancellationToken ct) + { + // Hardcoded SQL — no user input (CA2100) + const string sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;"; + var tables = new List(); + using var cmd = new NpgsqlCommand(sql, conn); + using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await r.ReadAsync(ct).ConfigureAwait(false)) + { + tables.Add(r.GetString(0)); + } + + return tables; + } + + /// Estimates total row count for the given tables using pg_class stats. + /// Open PostgreSQL connection. + /// Table names to estimate. + /// Cancellation token. + /// Approximate total row count across all specified tables. + internal static async Task EstimateTotalRowsAsync( + NpgsqlConnection conn, List tables, CancellationToken ct) + { + const string sql = "SELECT reltuples::bigint FROM pg_class WHERE relname = ANY(@tables) AND relkind = 'r';"; + long total = 0; + using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("tables", tables.ToArray()); + using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await r.ReadAsync(ct).ConfigureAwait(false)) + { + total += Math.Max(0, r.GetInt64(0)); + } + + return total; + } + + /// Returns ordered column definitions for a table from information_schema. + /// Open PostgreSQL connection. + /// Table name to query. + /// Cancellation token. + /// Ordered list of column name and type pairs. + internal static async Task> GetColumnSchemaAsync( + NpgsqlConnection conn, string table, CancellationToken ct) + { + const string colSql = @" + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = @t + ORDER BY ordinal_position;"; + var cols = new List(); + using var cmd = new NpgsqlCommand(colSql, conn); + cmd.Parameters.AddWithValue("t", table); + using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await r.ReadAsync(ct).ConfigureAwait(false)) + { + cols.Add(new ColumnInfo(r.GetString(0), r.GetString(1))); + } + + return cols; + } + + /// Returns the primary-key column names for a table. + /// Open PostgreSQL connection. + /// Table name to query. + /// Cancellation token. + /// Ordered list of primary key column names. + internal static async Task> GetPrimaryKeyColumnsAsync( + NpgsqlConnection conn, string table, CancellationToken ct) + { + const string pkSql = @" + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = 'public' AND tc.table_name = @t AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position;"; + var pks = new List(); + using var cmd = new NpgsqlCommand(pkSql, conn); + cmd.Parameters.AddWithValue("t", table); + using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await r.ReadAsync(ct).ConfigureAwait(false)) + { + pks.Add(r.GetString(0)); + } + + return pks; + } + + // ── SQLite introspection ────────────────────────────────────────────────── + + /// + /// Returns the column names of a table in SQLite, or an empty list if the table does not exist. + /// + /// Open SQLite connection. + /// Table name to inspect. + /// Cancellation token. + /// List of column names present in the SQLite table, or empty if the table does not exist. + internal static async Task> GetSqliteTableColumnsAsync( + SqliteConnection conn, string table, CancellationToken ct) + { + // First check if the table exists + using var checkCmd = conn.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@t;"; + checkCmd.Parameters.AddWithValue("@t", table); + var exists = Convert.ToInt64( + await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + CultureInfo.InvariantCulture) > 0; + + if (!exists) + { + return new List(); + } + + // Use PRAGMA table_info to get column names (table comes from pg_tables catalog, not user input) + var cols = new List(); + using var pragmaCmd = conn.CreateCommand(); + SetPragmaTableInfoCommand(pragmaCmd, table); + using var r = await pragmaCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await r.ReadAsync(ct).ConfigureAwait(false)) + { + cols.Add(r.GetString(r.GetOrdinal("name"))); + } + + return cols; + } + + // ── SQLite DDL ──────────────────────────────────────────────────────────── + + /// + /// Creates the table in SQLite if it doesn't already exist, + /// mirroring the PostgreSQL column types as SQLite affinities. + /// + /// Open PostgreSQL connection used to read the primary key definition. + /// Open SQLite connection where the table will be created. + /// Table name to create. + /// Column definitions from PostgreSQL. + /// Cancellation token. + /// A representing the asynchronous operation. + internal static async Task EnsureTableExistsAsync( + NpgsqlConnection pgConn, + SqliteConnection sqliteConn, + string table, + List schema, + CancellationToken ct) + { + using var checkCmd = sqliteConn.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@t;"; + checkCmd.Parameters.AddWithValue("@t", table); + var exists = Convert.ToInt64( + await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + CultureInfo.InvariantCulture) > 0; + + if (exists) + { + return; + } + + var pks = await GetPrimaryKeyColumnsAsync(pgConn, table, ct).ConfigureAwait(false); + var pkSet = new HashSet(pks, StringComparer.OrdinalIgnoreCase); + + var colDefs = schema.Select(c => + { + var affinity = MapToSqliteAffinity(c.PgDataType); + var pk = pkSet.Contains(c.Name) ? " PRIMARY KEY" : string.Empty; + return $" \"{c.Name}\" {affinity}{pk}"; + }); + + var ddl = $"CREATE TABLE IF NOT EXISTS \"{table}\" ({Environment.NewLine}{string.Join($",{Environment.NewLine}", colDefs)}{Environment.NewLine});"; + await ExecSqliteAsync(sqliteConn, ddl, ct).ConfigureAwait(false); + } + + /// + /// Executes a DDL/pragma SQL statement on the SQLite connection. + /// The parameter must only be called with hardcoded + /// or catalog-derived SQL (no user input). (CA2100). + /// + /// Open SQLite connection. + /// Hardcoded or catalog-derived DDL/pragma statement. + /// Cancellation token. + /// A representing the asynchronous operation. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "sql is always a hardcoded DDL statement or catalog-derived (CREATE TABLE/PRAGMA), never user input.")] + internal static async Task ExecSqliteAsync(SqliteConnection conn, string sql, CancellationToken ct) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + // CA2100: table name comes from pg_tables system catalog, not user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table comes from pg_tables system catalog, not user input. Identifier is quoted to prevent injection.")] + private static void SetPragmaTableInfoCommand(SqliteCommand cmd, string table) + { + cmd.CommandText = string.Concat( + "PRAGMA table_info(\"", + table.Replace("\"", "\"\"", StringComparison.Ordinal), + "\");"); + } + + // ── Type mapping ────────────────────────────────────────────────────────── + + private static string MapToSqliteAffinity(string pgDataType) + => pgDataType.ToLowerInvariant() switch + { + "boolean" => "INTEGER", + "integer" or "bigint" or "smallint" => "INTEGER", + "real" or "double precision" or "numeric" or "decimal" => "REAL", + "bytea" => "BLOB", + "array" => "TEXT", + _ => "TEXT", + }; +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs b/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs new file mode 100644 index 0000000..5466f01 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs @@ -0,0 +1,637 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Exports all Jellyfin data from PostgreSQL directly into a SQLite .db file. +/// +public sealed class ExportToSqliteService : IDisposable +{ + // ── Constants / static fields ───────────────────────────────────────────── + private const int CommitEveryRows = 10_000; + private const int MaxLogLines = 500; + + private static readonly HashSet SkipTables = new(StringComparer.OrdinalIgnoreCase) + { "__EFMigrationsHistory", "__EFMigrationsLock" }; + + // Jellyfin's native SQLite stores GUIDs in UPPERCASE. PostgreSQL uses lowercase. + // This pattern detects UUID strings so we can normalise them on export. + private static readonly System.Text.RegularExpressions.Regex GuidPattern = + new System.Text.RegularExpressions.Regex( + @"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + System.Text.RegularExpressions.RegexOptions.Compiled); + + // ── Instance fields ─────────────────────────────────────────────────────── + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly List _logBuffer = new(MaxLogLines); + private readonly object _logLock = new(); + + private volatile bool _isRunning; + private volatile bool _isCompleted; + private volatile bool _hasError; + private string? _errorMessage; + private int _percentComplete; + private string _currentTable = string.Empty; + private long _exportedRows; + private long _totalRows; + private string? _targetSqlitePath; + + // ── Constructor ─────────────────────────────────────────────────────────── + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public ExportToSqliteService(ILogger logger) => _logger = logger; + + // ── IDisposable ─────────────────────────────────────────────────────────── + + /// + public void Dispose() => _semaphore.Dispose(); + + // ── Public API ──────────────────────────────────────────────────────────── + + /// Returns the conventional SQLite database path for the given Jellyfin data directory. + /// Jellyfin data directory path. + /// Absolute path to the default jellyfin.db file. + public static string DetectDefaultSqlitePath(string dataPath) + => Path.Combine(dataPath, "jellyfin.db"); + + /// Gets a live snapshot of the export progress. + /// Current snapshot. + public ExportToSqliteProgress GetProgress() + { + lock (_logLock) + { + return new ExportToSqliteProgress( + IsRunning: _isRunning, + IsCompleted: _isCompleted, + HasError: _hasError, + ErrorMessage: _errorMessage, + PercentComplete: _percentComplete, + CurrentTable: _currentTable, + ExportedRows: _exportedRows, + TotalRows: _totalRows, + TargetSqlitePath: _targetSqlitePath, + LogLines: _logBuffer.ToList()); + } + } + + /// + /// Starts the export in a background task. + /// Returns if an export is already running. + /// + /// PostgreSQL connection string to export from. + /// Absolute path to the target SQLite file. + /// if the export started; if one is already running. + public bool StartExport(string pgConnStr, string sqliteDbPath) + { + if (!_semaphore.Wait(0)) + { + return false; + } + + _isRunning = true; + _isCompleted = false; + _hasError = false; + _errorMessage = null; + _percentComplete = 0; + _currentTable = string.Empty; + _exportedRows = 0; + _totalRows = 0; + _targetSqlitePath = sqliteDbPath; + + lock (_logLock) + { + _logBuffer.Clear(); + } + + PostgresLog.Warn($"[Export] INICIO: PostgreSQL → {sqliteDbPath}"); + + _ = Task.Run(async () => + { + try + { + await RunExportAsync(pgConnStr, sqliteDbPath, CancellationToken.None).ConfigureAwait(false); + _isCompleted = true; + PostgresLog.Warn($"[Export] COMPLETADA: {_exportedRows:N0} filas exportadas → {sqliteDbPath}"); + } + catch (Exception ex) + { + _hasError = true; + _errorMessage = ex.Message; + Log($"[FATAL] {ex.Message}"); + _logger.LogError(ex, "PostgreSQL → SQLite export failed."); + PostgresLog.Error("[Export] FALLIDA", ex); + } + finally + { + _isRunning = false; + _semaphore.Release(); + } + }); + + return true; + } + + // ── Core export logic ───────────────────────────────────────────────────── + + private async Task RunExportAsync(string pgConnStr, string sqliteDbPath, CancellationToken ct) + { + Log($"Iniciando exportación PostgreSQL → SQLite"); + Log($"Destino: {sqliteDbPath}"); + + var dir = Path.GetDirectoryName(sqliteDbPath); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + var pgBuilder = new NpgsqlConnectionStringBuilder(pgConnStr) + { + MaxAutoPrepare = 0, + Pooling = false, + CommandTimeout = 0, + }; + + using var pgConn = new NpgsqlConnection(pgBuilder.ConnectionString); + await pgConn.OpenAsync(ct).ConfigureAwait(false); + + 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}"); + + _totalRows = await ExportHelpers.EstimateTotalRowsAsync(pgConn, tables, ct).ConfigureAwait(false); + Log($"Filas estimadas: {_totalRows:N0}"); + PostgresLog.Warn($"[Export] Filas estimadas: {_totalRows:N0}"); + _percentComplete = 2; + + // File existence and schema are guaranteed by the controller before StartExport is called. + using var sqliteConn = new SqliteConnection($"Data Source={sqliteDbPath}"); + await sqliteConn.OpenAsync(ct).ConfigureAwait(false); + + await ExportHelpers.ExecSqliteAsync(sqliteConn, "PRAGMA foreign_keys = OFF;", ct).ConfigureAwait(false); + await ExportHelpers.ExecSqliteAsync(sqliteConn, "PRAGMA journal_mode = WAL;", ct).ConfigureAwait(false); + await ExportHelpers.ExecSqliteAsync(sqliteConn, "PRAGMA synchronous = NORMAL;", ct).ConfigureAwait(false); + + for (var i = 0; i < tables.Count; i++) + { + var table = tables[i]; + _currentTable = table; + _percentComplete = 2 + (int)((i / (double)tables.Count) * 95); + await ExportTableAsync(pgConn, sqliteConn, table, ct).ConfigureAwait(false); + } + + await ExportHelpers.ExecSqliteAsync(sqliteConn, "PRAGMA foreign_keys = ON;", ct).ConfigureAwait(false); + await ExportHelpers.ExecSqliteAsync(sqliteConn, "ANALYZE;", ct).ConfigureAwait(false); + + // Ensure library.db exists as a valid empty SQLite file. + // Jellyfin requires it to back up before running legacy code migrations + // (e.g. RemoveDuplicateExtras). Without it the migration service throws. + EnsureEmptyLibraryDb(sqliteDbPath); + + // Remove orphaned rows that reference non-existent Users, to prevent + // 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); + + _percentComplete = 100; + var fileSize = new FileInfo(sqliteDbPath).Length; + Log($"Exportación completada. {_exportedRows:N0} filas escritas."); + Log($"Archivo: {sqliteDbPath} ({FormatBytes(fileSize)})"); + } + + private async Task ExportTableAsync( + NpgsqlConnection pgConn, SqliteConnection sqliteConn, string table, CancellationToken ct) + { + // Only export columns that exist in BOTH PostgreSQL and the target SQLite. + // This preserves the original SQLite schema (with all FK constraints) intact. + var pgSchema = await ExportHelpers.GetColumnSchemaAsync(pgConn, table, ct).ConfigureAwait(false); + if (pgSchema.Count == 0) + { + return; + } + + // Get columns actually present in the SQLite table (if it exists) + var sqliteColumns = await ExportHelpers.GetSqliteTableColumnsAsync(sqliteConn, table, ct).ConfigureAwait(false); + + List schema; + if (sqliteColumns.Count == 0) + { + // Table does not exist in SQLite — skip it to preserve referential integrity. + Log($" [SKIP] {table}: no existe en SQLite destino, omitida."); + return; + } + else + { + // Use only columns present in both PG and SQLite + var sqliteColSet = new HashSet(sqliteColumns, StringComparer.OrdinalIgnoreCase); + schema = pgSchema.Where(c => sqliteColSet.Contains(c.Name)).ToList(); + } + + // table comes from pg_tables (system catalog), not user input (CA2100). + long rowCount; + using var countCmd = CreateCountCommand(pgConn, table); + rowCount = Convert.ToInt64( + await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + CultureInfo.InvariantCulture); + + if (rowCount == 0) + { + return; + } + + Log($" → {table} ({rowCount:N0} filas)"); + PostgresLog.Warn($"[Export] → {table} ({rowCount:N0} filas)"); + + await ExportHelpers.ExecSqliteAsync(sqliteConn, $"""DELETE FROM "{table}";""", ct).ConfigureAwait(false); + + var quotedCols = string.Join(", ", schema.Select(c => $"\"{c.Name}\"")); + var paramNames = string.Join(", ", schema.Select((_, i) => $"@p{i}")); + + // All identifiers from information_schema (system catalog), not user input (CA2100). + using var pgCmd = CreateSelectCommand(pgConn, table, quotedCols); + using var reader = await pgCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + using var insertCmd = CreateSqliteInsertCommand(sqliteConn, table, quotedCols, paramNames); + + var parameters = schema.Select((_, i) => + { + var p = insertCmd.CreateParameter(); + p.ParameterName = $"@p{i}"; + insertCmd.Parameters.Add(p); + return p; + }).ToList(); + + SqliteTransaction? activeTx = (SqliteTransaction)await sqliteConn.BeginTransactionAsync(ct).ConfigureAwait(false); + insertCmd.Transaction = activeTx; + long batchCount = 0; + + try + { + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + for (var col = 0; col < schema.Count; col++) + { + parameters[col].Value = ConvertForSqlite(reader.GetValue(col)); + } + + await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + batchCount++; + _exportedRows++; + + if (batchCount >= CommitEveryRows) + { + await activeTx.CommitAsync(ct).ConfigureAwait(false); + await activeTx.DisposeAsync().ConfigureAwait(false); + activeTx = (SqliteTransaction)await sqliteConn.BeginTransactionAsync(ct).ConfigureAwait(false); + insertCmd.Transaction = activeTx; + batchCount = 0; + } + } + + await activeTx.CommitAsync(ct).ConfigureAwait(false); + } + finally + { + await activeTx.DisposeAsync().ConfigureAwait(false); + } + } + + // ── Value conversion ────────────────────────────────────────────────────── + + // GUID format note: Jellyfin's native SQLite stores GUIDs in UPPERCASE + // (e.g. "DB139722-47D3-4C47-ADE9-F625715551CA"). PostgreSQL stores them + // 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 + { + null or DBNull => DBNull.Value, + bool b => b ? 1 : 0, + // GUIDs must be UPPERCASE to match Jellyfin's native SQLite format + Guid g => g.ToString().ToUpperInvariant(), + DateTime dt => dt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.UtcDateTime.ToString("O", CultureInfo.InvariantCulture), + float f when float.IsNaN(f) || float.IsInfinity(f) => DBNull.Value, + double d when double.IsNaN(d) || double.IsInfinity(d) => DBNull.Value, + long[] arr => JsonSerializer.Serialize(arr), + 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(), + _ => value, + }; + + // ── Logging ─────────────────────────────────────────────────────────────── + + private void Log(string message) + { + if (!string.IsNullOrEmpty(message)) + { + _logger.LogInformation("{Message}", message); + } + + lock (_logLock) + { + if (_logBuffer.Count >= MaxLogLines) + { + _logBuffer.RemoveAt(0); + } + + _logBuffer.Add($"[{DateTime.UtcNow:HH:mm:ss}] {message}"); + } + } + + private static string FormatBytes(long bytes) + { + if (bytes >= 1_073_741_824) + { + return string.Format(CultureInfo.InvariantCulture, "{0:F1} GB", bytes / 1_073_741_824.0); + } + + if (bytes >= 1_048_576) + { + return string.Format(CultureInfo.InvariantCulture, "{0:F1} MB", bytes / 1_048_576.0); + } + + if (bytes >= 1_024) + { + return string.Format(CultureInfo.InvariantCulture, "{0:F1} KB", bytes / 1_024.0); + } + + return string.Format(CultureInfo.InvariantCulture, "{0} B", bytes); + } + + // ── Command factory methods ───────────────────────────────────────────────────────── + // table/quotedCols come from pg_tables / information_schema (server catalog), not user input. + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table comes from pg_tables system catalog, not user input. Identifier is quoted.")] + private static NpgsqlCommand CreateCountCommand(NpgsqlConnection conn, string table) + { + var sql = string.Concat("SELECT COUNT(*) FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\";"); + return new NpgsqlCommand(sql, conn); + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table/quotedCols from information_schema catalog, not user input.")] + private static NpgsqlCommand CreateSelectCommand(NpgsqlConnection conn, string table, string quotedCols) + { + var sql = string.Concat("SELECT ", quotedCols, " FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\";"); + return new NpgsqlCommand(sql, conn) { CommandTimeout = 0 }; + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table/quotedCols from information_schema catalog, not user input.")] + private static SqliteCommand CreateSqliteInsertCommand( + SqliteConnection conn, string table, string quotedCols, string paramNames) + { + var cmd = conn.CreateCommand(); + var safeName = table.Replace("\"", "\"\"", StringComparison.Ordinal); + cmd.CommandText = string.Concat("INSERT INTO \"", safeName, "\" (", quotedCols, ") VALUES (", paramNames, ");"); + return cmd; + } + + // ── Orphan cleanup ──────────────────────────────────────────────────────── + // After export, some rows may reference Users that were not exported + // (e.g. system/service accounts). Deleting orphans prevents FK failures + // when Jellyfin activates FK enforcement at runtime. + + [SuppressMessage("Security", "CA2100", Justification = "Hardcoded cleanup SQL, no user input.")] + private async Task CleanOrphanedForeignKeysAsync(SqliteConnection conn, CancellationToken ct) + { + // Devices and ApiKeys hold session tokens tied to the PostgreSQL instance. + // When switching to SQLite mode those tokens are invalid anyway, and their + // UserId values may reference users that differ between the two engines. + // Clearing them forces Jellyfin to create fresh sessions on first login. + var cleanupSql = new[] + { + "DELETE FROM \"Devices\";", + "DELETE FROM \"DeviceOptions\";", + "DELETE FROM \"ApiKeys\" WHERE \"Name\" != 'Jellyfin';", + "DELETE FROM \"Permissions\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"Preferences\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"AccessSchedules\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"DisplayPreferences\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"ItemDisplayPreferences\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"CustomItemDisplayPreferences\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"UserData\" WHERE \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + "DELETE FROM \"HomeSection\" WHERE \"DisplayPreferencesId\" NOT IN (SELECT \"Id\" FROM \"DisplayPreferences\");", + "DELETE FROM \"ImageInfos\" WHERE \"UserId\" IS NOT NULL AND \"UserId\" NOT IN (SELECT \"Id\" FROM \"Users\");", + }; + + var totalDeleted = 0; + foreach (var sql in cleanupSql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + try + { + var deleted = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + if (deleted > 0) + { + totalDeleted += deleted; + Log($" [Cleanup] {deleted} filas huérfanas eliminadas: {sql[..Math.Min(60, sql.Length)]}..."); + } + } + catch (Exception ex) + { + // Table may not exist in this schema version — skip silently + Log($" [Cleanup] Omitido (tabla no existe): {ex.Message[..Math.Min(80, ex.Message.Length)]}"); + } + } + + if (totalDeleted > 0) + { + Log($"[PostExport] Limpieza FK: {totalDeleted} filas huérfanas eliminadas."); + PostgresLog.Warn($"[Export] FK cleanup: {totalDeleted} orphaned rows removed."); + } + } + + // ── library.db stub ────────────────────────────────────────────────────── + // Jellyfin's migration service tries to back up library.db before running + // legacy code migrations. If it doesn't exist (or is 0 bytes / malformed), + // the backup fails and the whole startup aborts. We create a minimal valid + // SQLite file so the backup succeeds and the legacy migrations can proceed + // (they will find no TypedBaseItems and exit gracefully, or be pre-marked). + + private static void EnsureEmptyLibraryDb(string sqliteDbPath) + { + var dataDir = Path.GetDirectoryName(sqliteDbPath) ?? string.Empty; + var libraryDbPath = Path.Combine(dataDir, "library.db"); + + if (File.Exists(libraryDbPath) && new FileInfo(libraryDbPath).Length >= 4096) + { + return; // already a valid SQLite file + } + + // Minimal valid SQLite database: one 4096-byte page with correct header. + // Page size stored at offset 16 (big-endian uint16): 0x10 0x00 = 4096. + var page = new byte[4096]; + var magic = System.Text.Encoding.ASCII.GetBytes("SQLite format 3\0"); + Array.Copy(magic, page, magic.Length); + page[16] = 0x10; // page size high byte (4096 = 0x1000) + page[17] = 0x00; // page size low byte + page[18] = 1; // file format write version + page[19] = 1; // file format read version + page[20] = 0; // reserved bytes per page + page[21] = 64; // max embedded payload fraction + page[22] = 32; // min embedded payload fraction + page[23] = 32; // leaf payload fraction + + File.WriteAllBytes(libraryDbPath, page); + PostgresLog.Warn($"[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) + { + 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); + 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)) + { + 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++; + } + + Log($"[PostExport] {inserted} migraciones pre-marcadas correctamente."); + PostgresLog.Warn($"[Export] {inserted} migraciones de código pre-marcadas en __EFMigrationsHistory."); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs b/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs new file mode 100644 index 0000000..f6d711c --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs @@ -0,0 +1,867 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// PostgreSQL health diagnostics: runs a battery of read-only checks against +/// pg_catalog / pg_stat views and reports structured findings with a severity +/// semaphore. Also supports auto-repair of issues that are safely fixable +/// at runtime (invalid indexes, table bloat, out-of-sync sequences). +/// +public sealed partial class HealthCheckService +{ + /// Bloat ratio (dead tuples over live+dead) above which a table is flagged. + private const double BloatWarnRatio = 0.20; + + /// Tables are only flagged for stale statistics when they contain at least this many rows. + private const long AnalyzeMinLiveTuples = 100; + + /// + /// PostgreSQL internal schemas where only a superuser can DROP indexes. + /// Orphan indexes in these schemas are reported as Warn with a superuser hint. + /// + private static readonly HashSet SuperuserSchemas = + new(StringComparer.OrdinalIgnoreCase) { "pg_toast", "pg_catalog", "information_schema" }; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public HealthCheckService(ILogger logger) => _logger = logger; + + /// + /// Runs all health checks in parallel and aggregates the findings. + /// + /// PostgreSQL connection string. + /// Schema to inspect. Defaults to public. + /// + /// When , suppresses the INICIO/COMPLETADO log lines. + /// Use for the automatic startup check to avoid duplicating the summary written + /// by LogHealthSummary in PostgresDatabaseProvider. + /// + /// Cancellation token. + /// Aggregated . + public async Task RunHealthCheckAsync( + string connectionString, + string schema = "public", + bool silent = false, + CancellationToken ct = default) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + if (!silent) + { + PostgresLog.Info($"[HealthCheck] INICIO: diagnóstico en schema '{schema}'..."); + } + + // Each check runs in parallel and is wrapped so a single failure + // (e.g. an unsupported catalog view) never aborts the whole report. + var invalidIndexesTask = CaptureCheckAsync("InvalidIndexes", CheckInvalidIndexesAsync(connectionString, ct)); + var bloatTask = CaptureCheckAsync("TableBloat", CheckTableBloatAsync(connectionString, schema, ct)); + var sequencesTask = CaptureCheckAsync("SequenceDesync", CheckSequenceSyncAsync(connectionString, schema, ct)); + var analyzeTask = CaptureCheckAsync("StaleAnalyze", CheckStaleAnalyzeAsync(connectionString, schema, ct)); + var unusedIndexesTask = CaptureCheckAsync("UnusedIndex", CheckUnusedIndexesAsync(connectionString, schema, ct)); + var slowQueriesTask = CaptureCheckAsync("SlowQueries", CheckSlowQueriesAsync(connectionString, ct)); + + // Tasks are already running in parallel; awaiting each one lets + // CaptureCheckAsync turn any individual failure into a finding. + var findings = new List(64); + findings.AddRange(await invalidIndexesTask.ConfigureAwait(false)); + findings.AddRange(await bloatTask.ConfigureAwait(false)); + findings.AddRange(await sequencesTask.ConfigureAwait(false)); + findings.AddRange(await analyzeTask.ConfigureAwait(false)); + findings.AddRange(await unusedIndexesTask.ConfigureAwait(false)); + findings.AddRange(await slowQueriesTask.ConfigureAwait(false)); + + var overall = WorstSeverity(findings); + sw.Stop(); + + if (!silent) + { + LogFindings(findings); + PostgresLog.Warn( + $"[HealthCheck] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s | Severidad: {overall} | " + + $"Hallazgos: {findings.Count} | Schema: {schema}"); + } + + _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); + } + + /// + /// Auto-repairs the issues detected by . + /// Re-runs detection internally so it is idempotent and never trusts client-supplied + /// identifiers. Executes: REINDEX CONCURRENTLY for invalid indexes, VACUUM ANALYZE + /// for bloated tables, and setval for out-of-sync sequences. + /// + /// PostgreSQL connection string. + /// Schema to inspect. Defaults to public. + /// Cancellation token. + /// Aggregated . + public async Task RepairAsync( + string connectionString, + string schema = "public", + CancellationToken ct = default) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var items = new List(); + + PostgresLog.Warn($"[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); + var ccnewSkipped = new List(); + foreach (var (idxSchema, table, index) in invalidIndexes) + { + var target = $"{idxSchema}.{table}.{index}"; + var isCcnew = index.EndsWith("_ccnew", StringComparison.OrdinalIgnoreCase) + || index.Contains("_ccnew_", StringComparison.OrdinalIgnoreCase); + + if (isCcnew || SuperuserSchemas.Contains(idxSchema)) + { + ccnewSkipped.Add(target); + var hint = SuperuserSchemas.Contains(idxSchema) + ? "Requiere superusuario (postgres): DROP INDEX pg_toast.\"" + index + "\";" + : "Ejecuta DROP INDEX para eliminarlo manualmente."; + items.Add(new HealthRepairItem("SkipCcnew", target, false, hint)); + continue; + } + + items.Add(await RepairIndexAsync(connectionString, idxSchema, table, index, ct).ConfigureAwait(false)); + } + + if (ccnewSkipped.Count > 0) + { + PostgresLog.Warn( + $"[HealthCheck] SKIP {ccnewSkipped.Count} índice(s) — requieren intervención manual: " + + string.Join(", ", ccnewSkipped)); + } + + var bloatedTables = await FindBloatedTablesAsync(connectionString, schema, ct).ConfigureAwait(false); + foreach (var (table, deadPct) in bloatedTables) + { + items.Add(await RepairBloatAsync(connectionString, schema, table, deadPct, ct).ConfigureAwait(false)); + } + + var staleSequences = await FindSequenceIssuesAsync(connectionString, schema, ct).ConfigureAwait(false); + foreach (var seq in staleSequences) + { + items.Add(await RepairSequenceAsync(connectionString, schema, seq, ct).ConfigureAwait(false)); + } + + var overallSuccess = items.All(static i => i.Success); + sw.Stop(); + + PostgresLog.Warn( + $"[HealthCheck] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s: " + + $"{items.Count} acciones ({items.Count(static i => i.Success)} ok, {items.Count(static i => !i.Success)} fallaron)."); + + return new HealthRepairResult(overallSuccess, items, DateTime.UtcNow, sw.ElapsedMilliseconds); + } + + // ── Check 1: invalid indexes ────────────────────────────────────────────── + + /// + /// Detects indexes marked as invalid across ALL schemas. Separates: + /// + /// _ccnew orphans in system schemas — require superuser DROP INDEX + /// _ccnew orphans in user schemas — require manual DROP INDEX + /// Ordinary invalid indexes — auto-repairable with REINDEX CONCURRENTLY + /// + /// + private static async Task> CheckInvalidIndexesAsync( + string connectionString, CancellationToken ct) + { + // Search all schemas so pg_toast orphans are also visible. + var invalid = await FindInvalidIndexesAsync(connectionString, ct).ConfigureAwait(false); + if (invalid.Count == 0) + { + return Array.Empty(); + } + + static bool IsCcnew(string idx) => + idx.EndsWith("_ccnew", StringComparison.OrdinalIgnoreCase) || + idx.Contains("_ccnew_", StringComparison.OrdinalIgnoreCase); + + var ccnewSuperuser = invalid.Where(p => SuperuserSchemas.Contains(p.Schema) && IsCcnew(p.Index)).ToList(); + var ccnewUser = invalid.Where(p => !SuperuserSchemas.Contains(p.Schema) && IsCcnew(p.Index)).ToList(); + var repairable = invalid.Except(ccnewSuperuser).Except(ccnewUser).ToList(); + + var findings = new List(3); + + if (ccnewSuperuser.Count > 0) + { + var names = string.Join(", ", ccnewSuperuser.Select(static p => $"{p.Schema}.{p.Table}.{p.Index}")); + findings.Add(new HealthFinding( + Check: "OrphanCcnewIndexesSuperuser", + Severity: HealthSeverity.Warn, + Message: $"{ccnewSuperuser.Count} índice(s) _ccnew huérfano(s) en schemas del sistema ({string.Join(", ", ccnewSuperuser.Select(static p => p.Schema).Distinct())}). " + + "El usuario de la BD no tiene permisos para eliminarlos. " + + "Conectáte como superusuario (postgres) y ejecuta DROP INDEX para cada uno.", + Detail: names, + Repairable: false)); + } + + if (ccnewUser.Count > 0) + { + var names = string.Join(", ", ccnewUser.Select(static p => $"{p.Table}.{p.Index}")); + findings.Add(new HealthFinding( + Check: "OrphanCcnewIndexes", + Severity: HealthSeverity.Error, + Message: $"{ccnewUser.Count} índice(s) _ccnew huérfano(s) de un REINDEX CONCURRENTLY interrumpido. " + + "No pueden repararse automáticamente — ejecuta DROP INDEX para cada uno.", + Detail: names, + Repairable: false)); + } + + if (repairable.Count > 0) + { + var names = string.Join(", ", repairable.Select(static p => $"{p.Table}.{p.Index}")); + findings.Add(new HealthFinding( + Check: "InvalidIndexes", + Severity: HealthSeverity.Error, + Message: $"{repairable.Count} índice(s) inválido(s) encontrado(s). Los queries que los usan fallarán o serán muy lentos.", + Detail: names, + Repairable: true)); + } + + return findings; + } + + // ── Check 2: table bloat ────────────────────────────────────────────────── + + /// Detects tables whose dead-tuple ratio exceeds the warn threshold. + private static async Task> CheckTableBloatAsync( + string connectionString, string schema, CancellationToken ct) + { + var bloated = await FindBloatedTablesAsync(connectionString, schema, ct).ConfigureAwait(false); + if (bloated.Count == 0) + { + return Array.Empty(); + } + + var findings = new List(bloated.Count); + foreach (var (table, deadPct) in bloated) + { + findings.Add(new HealthFinding( + Check: "TableBloat", + Severity: HealthSeverity.Warn, + Message: $"'{table}' tiene {deadPct.ToString("F1", CultureInfo.InvariantCulture)}% de tuplas muertas. " + + "Un VACUUM ANALYZE recuperará espacio y refrescará el planner.", + Detail: $"{table} ({deadPct.ToString("F1", CultureInfo.InvariantCulture)}%)", + Repairable: true)); + } + + return findings; + } + + // ── Check 3: sequence sync ──────────────────────────────────────────────── + + /// Detects identity sequences whose last_value trails the max column value. + private static async Task> CheckSequenceSyncAsync( + string connectionString, string schema, CancellationToken ct) + { + var issues = await FindSequenceIssuesAsync(connectionString, schema, ct).ConfigureAwait(false); + if (issues.Count == 0) + { + return Array.Empty(); + } + + var findings = new List(issues.Count); + foreach (var seq in issues) + { + findings.Add(new HealthFinding( + Check: "SequenceDesync", + Severity: HealthSeverity.Error, + Message: $"Secuencia '{seq.Sequence}' está detrás del máximo de {seq.Table}.{seq.Column} " + + $"(last_value={seq.LastValue:N0}, max={seq.MaxValue:N0}). El próximo INSERT puede fallar con PK duplicado.", + Detail: $"{seq.Sequence}", + Repairable: true)); + } + + return findings; + } + + // ── Check 4: stale analyze stats ────────────────────────────────────────── + + /// Detects populated tables without a recent ANALYZE (7+ days). + private static async Task> CheckStaleAnalyzeAsync( + string connectionString, string schema, CancellationToken ct) + { + const string sql = @" + SELECT relname, n_live_tup + FROM pg_stat_user_tables + WHERE schemaname = @schema + AND n_live_tup >= @minLive + AND (last_analyze IS NULL OR last_analyze < NOW() - INTERVAL '7 days') + AND (last_autoanalyze IS NULL OR last_autoanalyze < NOW() - INTERVAL '7 days') + ORDER BY relname;"; + + var findings = new List(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(sql, pg); + cmd.Parameters.AddWithValue("schema", schema); + cmd.Parameters.AddWithValue("minLive", AnalyzeMinLiveTuples); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var table = reader.GetString(0); + var live = reader.GetInt64(1); + findings.Add(new HealthFinding( + Check: "StaleAnalyze", + Severity: HealthSeverity.Warn, + Message: $"'{table}' ({live:N0} filas) no tiene estadísticas ANALYZE recientes. " + + "El planner puede elegir planes subóptimos.", + Detail: table, + Repairable: false)); + } + + return findings; + } + + // ── Check 6: slow queries (pg_stat_statements) ─────────────────────── + + /// + /// Reports the top slow queries from pg_stat_statements. + /// Silently skips when the extension is not installed — no finding is emitted. + /// Queries are classified as Warn (≥ 500 ms mean) or Ok (informational). + /// + private static async Task> CheckSlowQueriesAsync( + string connectionString, CancellationToken ct) + { + const string checkSql = """ + SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_stat_statements'; + """; + + const string slowSql = """ + SELECT + LEFT(query, 120) AS query_preview, + ROUND(mean_exec_time::numeric, 2) AS mean_ms, + calls, + ROUND(total_exec_time::numeric, 2) AS total_ms, + ROUND(rows::numeric / NULLIF(calls, 0), 1) AS avg_rows + FROM pg_stat_statements + WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND query NOT LIKE '%pg\_%' + AND query NOT LIKE '%pg\_stat%' + AND mean_exec_time > 100 + AND calls > 2 + ORDER BY mean_exec_time DESC + LIMIT 10; + """; + + // Warn threshold: queries averaging more than this are flagged. + const double WarnThresholdMs = 500.0; + + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + + // Check if extension is installed; skip silently if not. + using (var checkCmd = new NpgsqlCommand(checkSql, pg)) + { + var extCount = Convert.ToInt64( + await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + CultureInfo.InvariantCulture); + if (extCount == 0) + { + return Array.Empty(); + } + } + + var slow = new List(); + using (var cmd = new NpgsqlCommand(slowSql, pg)) + using (var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false)) + { + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + slow.Add(new SlowQueryInfo( + QueryPreview: reader.GetString(0).Trim(), + MeanMs: Convert.ToDouble(reader.GetValue(1), CultureInfo.InvariantCulture), + Calls: reader.GetInt64(2), + TotalMs: Convert.ToDouble(reader.GetValue(3), CultureInfo.InvariantCulture), + Rows: Convert.ToDouble(reader.GetValue(4), CultureInfo.InvariantCulture))); + } + } + + if (slow.Count == 0) + { + return Array.Empty(); + } + + var findings = new List(slow.Count); + foreach (var q in slow) + { + var severity = q.MeanMs >= WarnThresholdMs ? HealthSeverity.Warn : HealthSeverity.Ok; + var detail = $"media={q.MeanMs:F0} ms | llamadas={q.Calls:N0} | total={q.TotalMs / 1000.0:F1} s | filas̅={q.Rows:F0}"; + findings.Add(new HealthFinding( + Check: "SlowQuery", + Severity: severity, + Message: $"Query lenta detectada (media {q.MeanMs:F0} ms en {q.Calls:N0} llamada(s)): {q.QueryPreview}", + Detail: detail, + Repairable: false)); + } + + return findings; + } + + // ── Check 5: unused indexes ─────────────────────────────────────────────── + + /// Detects user indexes never scanned (candidates for removal). + private static async Task> CheckUnusedIndexesAsync( + string connectionString, string schema, CancellationToken ct) + { + const string sql = @" + SELECT relname, indexrelname + FROM pg_stat_user_indexes + WHERE schemaname = @schema + AND idx_scan = 0 + AND indexrelname NOT LIKE '%_pkey' + ORDER BY relname, indexrelname;"; + + var findings = new List(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(sql, pg); + cmd.Parameters.AddWithValue("schema", schema); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var table = reader.GetString(0); + var index = reader.GetString(1); + findings.Add(new HealthFinding( + Check: "UnusedIndex", + Severity: HealthSeverity.Ok, + Message: $"El índice '{index}' de '{table}' nunca ha sido usado por el planner. " + + "Podría eliminarse para reducir el costo de escritura.", + Detail: $"{table}.{index}", + Repairable: false)); + } + + return findings; + } + + // ── Detection helpers (shared by checks and repair) ────────────────────── + + /// + /// Returns (schema, table, index) tuples for every invalid index across all schemas. + /// Searching all schemas ensures pg_toast orphans left by interrupted REINDEX operations + /// are also detected, even though only a superuser can drop them. + /// + private static async Task> FindInvalidIndexesAsync( + string connectionString, CancellationToken ct) + { + const string sql = @" + SELECT n.nspname AS schema_name, t.relname AS table_name, c.relname AS index_name + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT i.indisvalid + ORDER BY n.nspname, t.relname, c.relname;"; + + var result = new List<(string, string, string)>(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(sql, pg); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + result.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); + } + + return result; + } + + /// Returns (table, deadPct) pairs for bloated tables above the warn threshold. + private static async Task> FindBloatedTablesAsync( + string connectionString, string schema, CancellationToken ct) + { + const string sql = @" + SELECT relname, + n_live_tup, + n_dead_tup, + ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct + FROM pg_stat_user_tables + WHERE schemaname = @schema + AND n_dead_tup > 1000 + ORDER BY dead_pct DESC + LIMIT 20;"; + + var result = new List<(string, double)>(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(sql, pg); + cmd.Parameters.AddWithValue("schema", schema); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var table = reader.GetString(0); + var live = reader.GetInt64(1); + var dead = reader.GetInt64(2); + var deadPct = reader.GetDouble(3); + + // Only flag when the table is actually bloated, not just recently churned. + var total = live + dead; + if (total > 0 && dead / (double)total >= BloatWarnRatio) + { + result.Add((table, deadPct)); + } + } + + return result; + } + + /// Returns identity sequences whose last_value trails the max column value. + private static async Task> FindSequenceIssuesAsync( + string connectionString, string schema, CancellationToken ct) + { + const string sequenceSql = @" + SELECT n.nspname AS schema_name, + c.relname AS table_name, + a.attname AS column_name, + pg_get_expr(ad.adbin, ad.adrelid) AS default_expr + FROM pg_attrdef ad + JOIN pg_class c ON c.oid = ad.adrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ad.adnum + WHERE n.nspname = @schema + AND pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval%' + ORDER BY c.relname, a.attname;"; + + var issues = new List(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + + using (var cmd = new NpgsqlCommand(sequenceSql, pg)) + { + cmd.Parameters.AddWithValue("schema", schema); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + var candidates = new List<(string Schema, string Table, string Column, string Sequence)>(); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var expr = reader.GetString(3); + var seqMatch = NextvalRegex().Match(expr); + if (!seqMatch.Success) + { + continue; + } + + var seqName = seqMatch.Groups[1].Value.Trim('\'', '"', ' '); + candidates.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2), seqName)); + } + + foreach (var cand in candidates) + { + if (ct.IsCancellationRequested) + { + break; + } + + var issue = await ProbeSequenceAsync(pg, cand.Schema, cand.Table, cand.Column, cand.Sequence, ct) + .ConfigureAwait(false); + if (issue is not null) + { + issues.Add(issue); + } + } + } + + return issues; + } + + /// Compares a sequence's last_value against the max of its owning column. + private static async Task ProbeSequenceAsync( + NpgsqlConnection pg, + string schema, + string table, + string column, + string sequence, + CancellationToken ct) + { + try + { + // Fail-closed: identifiers returned by the catalog are validated before use. + var safeSchema = RequireSqlIdentifier(schema, "schema"); + var safeTable = RequireSqlIdentifier(table, "tabla"); + var safeColumn = RequireSqlIdentifier(column, "columna"); + var safeSequence = RequireSqlIdentifier(sequence, "secuencia"); + + var lastValue = await ExecuteScalarLongAsync( + pg, + $"SELECT last_value FROM {MaintenanceService.QuoteIdentifier(safeSequence)};", + ct).ConfigureAwait(false); + + var maxValue = await ExecuteScalarLongAsync( + pg, + $"SELECT COALESCE(MAX({MaintenanceService.QuoteIdentifier(safeColumn)}), 0) " + + $"FROM {MaintenanceService.QuoteIdentifier(safeSchema)}.{MaintenanceService.QuoteIdentifier(safeTable)};", + ct).ConfigureAwait(false); + + // A sequence that trails the column max would hand out an existing value + // on the next INSERT → duplicate PK error. + return lastValue < maxValue + ? new SequenceIssue(schema, table, column, sequence, lastValue, maxValue) + : null; + } + catch (Exception ex) + { + // Some sequences may be detached or otherwise unreadable — skip, don't fail the whole check. + System.Diagnostics.Debug.WriteLine(ex.Message); + return null; + } + } + + // ── Repair actions ──────────────────────────────────────────────────────── + + /// Rebuilds a single invalid index with REINDEX INDEX CONCURRENTLY. + private async Task RepairIndexAsync( + string connectionString, string schema, string table, string index, CancellationToken ct) + { + var target = $"{schema}.{table}.{index}"; + try + { + // Fail-closed: validate before interpolating into DDL. + var safeSchema = RequireSqlIdentifier(schema, "schema"); + var safeIndex = RequireSqlIdentifier(index, "índice"); + + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + 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}"); + return new HealthRepairItem("Reindex", target, true, "Índice reconstruido correctamente."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reindex {Target}", target); + PostgresLog.Error($"[HealthCheck] REINDEX falló: {target} — {ex.Message}"); + return new HealthRepairItem("Reindex", target, false, ex.Message); + } + } + + /// Runs VACUUM ANALYZE on a single bloated table. + private async Task RepairBloatAsync( + string connectionString, string schema, string table, double deadPct, CancellationToken ct) + { + var target = $"{schema}.{table}"; + try + { + // Fail-closed: validate before interpolating into DDL. + var safeSchema = RequireSqlIdentifier(schema, "schema"); + var safeTable = RequireSqlIdentifier(table, "tabla"); + + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + 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}"); + return new HealthRepairItem( + "Vacuum", + target, + true, + $"VACUUM ANALYZE completado (bloat {deadPct.ToString("F1", CultureInfo.InvariantCulture)}%)."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to vacuum {Target}", target); + PostgresLog.Error($"[HealthCheck] VACUUM falló: {target} — {ex.Message}"); + return new HealthRepairItem("Vacuum", target, false, ex.Message); + } + } + + /// Advances a stale sequence to its column max with setval. + private async Task RepairSequenceAsync( + string connectionString, string schema, SequenceIssue issue, CancellationToken ct) + { + var target = $"{schema}.{issue.Sequence}"; + try + { + // Fail-closed: validate before interpolating into DDL. + var safeSequence = RequireSqlIdentifier(issue.Sequence, "secuencia"); + + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + 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}"); + return new HealthRepairItem( + "FixSequence", + target, + true, + $"setval({issue.MaxValue.ToString("N0", CultureInfo.InvariantCulture)}) aplicado a {issue.Table}.{issue.Column}."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fix sequence {Target}", target); + PostgresLog.Error($"[HealthCheck] FIX SEQ falló: {target} — {ex.Message}"); + return new HealthRepairItem("FixSequence", target, false, ex.Message); + } + } + + // ── Shared SQL helpers (defense in depth) ───────────────────────────────── + + /// Computes the worst severity across all findings (Error > Warn > Ok). + private static HealthSeverity WorstSeverity(List findings) + { + if (findings.Any(static f => f.Severity == HealthSeverity.Error)) + { + return HealthSeverity.Error; + } + + return findings.Any(static f => f.Severity == HealthSeverity.Warn) + ? HealthSeverity.Warn + : HealthSeverity.Ok; + } + + /// + /// Awaits a check task and converts any exception into an Error finding so the + /// remaining checks still complete and the failure is visible in the report. + /// + /// Short identifier of the check. + /// The running check task. + /// The check findings, or a single Error finding if the check threw. + private static async Task> CaptureCheckAsync( + string checkName, + Task> task) + { + try + { + return await task.ConfigureAwait(false); + } + catch (Exception ex) + { + PostgresLog.Error($"[HealthCheck] '{checkName}' falló: {ex.Message}"); + return new[] + { + new HealthFinding( + Check: checkName, + Severity: HealthSeverity.Error, + Message: $"No se pudo ejecutar el check '{checkName}': {ex.Message}", + Detail: ex.GetType().Name, + Repairable: false), + }; + } + } + + /// + /// Writes a compact summary to the plugin log: a per-severity count, then the + /// individual findings that require attention (Warn/Error). Informational findings + /// are only counted so the log stays readable on large databases. + /// + /// Findings to summarize in the plugin log. + private static void LogFindings(List findings) + { + var errors = findings.Count(static f => f.Severity == HealthSeverity.Error); + var warns = findings.Count(static f => f.Severity == HealthSeverity.Warn); + var info = findings.Count - errors - warns; + + PostgresLog.Warn( + $"[HealthCheck] Resumen: {errors} error(es), {warns} advertencia(s), {info} informativo(s) | Total: {findings.Count}"); + + // Only findings that need attention are listed individually; the rest are just counted. + foreach (var f in findings) + { + if (f.Severity == HealthSeverity.Ok) + { + continue; + } + + var detail = string.IsNullOrEmpty(f.Detail) ? string.Empty : $" | {f.Detail}"; + PostgresLog.Warn($"[HealthCheck] [{f.Check}] {f.Message}{detail}"); + } + } + + /// + /// Validates that a name returned by the server catalog is a plain SQL identifier + /// before it is ever interpolated into a command. Fail-closed: any unexpected value + /// throws and aborts the operation instead of being embedded into SQL. + /// + /// Identifier returned by PostgreSQL catalog views. + /// Human-readable kind used in the error message (e.g. "tabla"). + /// The validated identifier. + /// When is not a safe SQL identifier. + private static string RequireSqlIdentifier(string name, string kind) + { + if (string.IsNullOrWhiteSpace(name) || !SqlIdentifierRegex().IsMatch(name)) + { + throw new InvalidOperationException( + $"El servidor devolvió un {kind} no esperado ('{name}'); se cancela la operación por seguridad."); + } + + return name; + } + + // SQL text is only ever assembled by this class from identifiers that passed + // RequireSqlIdentifier (strict ^[A-Za-z_][A-Za-z0-9_]*$) and QuoteIdentifier, + // plus numeric long values formatted with the invariant culture. No user input + // can reach the command text, so the analyzer flags are false positives here. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL is assembled by this class only from identifiers validated with RequireSqlIdentifier (strict ^[A-Za-z_][A-Za-z0-9_]*$) and escaped with QuoteIdentifier; values are numeric longs. No user input.")] + [SuppressMessage("Security", "CA3001:Review code for SQL injection vulnerabilities", Justification = "SQL is assembled by this class only from identifiers validated with RequireSqlIdentifier (strict ^[A-Za-z_][A-Za-z0-9_]*$) and escaped with QuoteIdentifier; values are numeric longs. No user input.")] + private static async Task ExecuteNonQueryAsync(NpgsqlConnection pg, string sql, CancellationToken ct) + { + using var cmd = new NpgsqlCommand(sql, pg) { CommandTimeout = 0 }; + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + // Same guarantees as ExecuteNonQueryAsync: receives SQL already built from + // validated identifiers. This is the only other place a command is created. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL is assembled by this class only from identifiers validated with RequireSqlIdentifier (strict ^[A-Za-z_][A-Za-z0-9_]*$) and escaped with QuoteIdentifier; values are numeric longs. No user input.")] + [SuppressMessage("Security", "CA3001:Review code for SQL injection vulnerabilities", Justification = "SQL is assembled by this class only from identifiers validated with RequireSqlIdentifier (strict ^[A-Za-z_][A-Za-z0-9_]*$) and escaped with QuoteIdentifier; values are numeric longs. No user input.")] + private static async Task ExecuteScalarLongAsync(NpgsqlConnection pg, string sql, CancellationToken ct) + { + using var cmd = new NpgsqlCommand(sql, pg); + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return Convert.ToInt64(result, CultureInfo.InvariantCulture); + } + + [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)] + private static partial Regex SqlIdentifierRegex(); + + [GeneratedRegex("nextval\\('([^']+)'::regclass\\)", RegexOptions.IgnoreCase)] + private static partial Regex NextvalRegex(); + + /// Describes an identity sequence that has fallen behind its column maximum. + /// Schema owning the sequence. + /// Table that owns the sequence. + /// Column that consumes the sequence. + /// Sequence name. + /// Current last_value of the sequence. + /// Maximum value present in the column. + private sealed record SequenceIssue( + string Schema, + string Table, + string Column, + string Sequence, + long LastValue, + long MaxValue); +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs new file mode 100644 index 0000000..5984b50 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// EF Core interceptor that caches read-only query results in memory for a short TTL. +/// Home-page queries (Latest, Resume, NextUp, Libraries) that run on every page load +/// are answered from cache without touching PostgreSQL, benefiting all clients equally. +/// +/// +/// Cache policy: only SELECT queries on the core media tables (BaseItems, UserData, +/// ItemValues) are eligible. Mutations, DDL, and schema queries bypass the cache. +/// TTL: 30 seconds — short enough that playback progress is never more than +/// one refresh cycle stale, long enough to absorb the home-page fan-out (~15 queries). +/// +public sealed class HomeQueryCacheInterceptor : DbCommandInterceptor, IDisposable +{ + // Fields first (SA1201: fields before properties) + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + + // Only intercept queries on the tables that Jellyfin fans out on home-page load. + private static readonly HashSet CacheableTables = new(StringComparer.OrdinalIgnoreCase) + { + "\"BaseItems\"", + "\"UserData\"", + "\"ItemValues\"", + "\"ItemValuesMap\"", + "\"PeopleBaseItemMap\"", + }; + + private readonly ILogger? _logger; + private readonly MemoryCache _cache; + + /// + /// Initializes a new instance of the class. + /// + /// Optional logger for cache diagnostics. + public HomeQueryCacheInterceptor(ILogger? logger = null) + { + _logger = logger; + Instance = this; + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 200, + }); + } + + /// Gets the singleton set after construction, for cache purge operations. + internal static HomeQueryCacheInterceptor? Instance { get; private set; } + + /// + public void Dispose() + { + _cache.Dispose(); + } + + /// + public override async ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (!IsCacheable(command)) + { + return result; + } + + // Queries with ORDER BY RANDOM() are never cached (should be fresh on every request), + // but we rewrite them to use TABLESAMPLE BERNOULLI — O(1) vs O(n log n). + if (HasOrderByRandom(command)) + { + return await RewriteRandomAndExecuteAsync(command, cancellationToken).ConfigureAwait(false); + } + + var cacheKey = BuildCacheKey(command); + if (cacheKey is null) + { + return result; + } + + if (_cache.TryGetValue(cacheKey, out DataTable? cached) && cached is not null) + { + _logger?.LogDebug( + "HomeQueryCache HIT: {SqlPreview}", + command.CommandText[..Math.Min(command.CommandText.Length, 120)]); + return InterceptionResult.SuppressWithResult(new DataTableReader(cached)); + } + + // Miss — execute the command ourselves so we can capture the full result set + // into a DataTable before EF Core consumes the reader. + return await ExecuteAndCacheAsync(command, cacheKey, cancellationToken).ConfigureAwait(false); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cache eligibility + // ───────────────────────────────────────────────────────────────────────── + + private static bool IsCacheable(DbCommand command) + { + var sql = command.CommandText; + if (sql.Length == 0) + { + return false; + } + + // Only SELECT statements + var firstWord = FirstToken(sql); + if (firstWord is null || !firstWord.Equals("SELECT", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Must reference at least one cacheable table + foreach (var table in CacheableTables) + { + if (sql.Contains(table, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string? FirstToken(string sql) + { + var span = sql.AsSpan().TrimStart(); + if (span.IsEmpty) + { + return null; + } + + var end = 0; + while (end < span.Length && !char.IsWhiteSpace(span[end])) + { + end++; + } + + return span[..end].ToString(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cache key: SHA-256 of (SQL + serialized parameter values) + // ───────────────────────────────────────────────────────────────────────── + + private static string? BuildCacheKey(DbCommand command) + { + // Use pooled StringBuilder for hot path + var sb = new StringBuilder(command.CommandText, command.CommandText.Length + 256); + + foreach (DbParameter p in command.Parameters) + { + sb.Append('|'); + sb.Append(p.ParameterName); + sb.Append('='); + var val = p.Value; + if (val is null || val == DBNull.Value) + { + sb.Append("NULL"); + } + else if (val is string s) + { + // Truncate long strings — cache keys don't need full text + sb.Append(s.AsSpan(0, Math.Min(s.Length, 80))); + } + else if (val is DateTime dt) + { + sb.Append(dt.ToString("O", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(val is IFormattable f + ? f.ToString(null, CultureInfo.InvariantCulture) + : val.ToString()); + } + } + + var raw = sb.ToString(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexStringLower(hash); + } + + // ───────────────────────────────────────────────────────────────────────── + // Self-execute + cache + // ───────────────────────────────────────────────────────────────────────── + + private async ValueTask> ExecuteAndCacheAsync( + DbCommand command, + string cacheKey, + CancellationToken ct) + { + try + { + // Ensure connection is open (EF Core normally handles this, but we're executing ourselves) + if (command.Connection!.State != ConnectionState.Open) + { + await command.Connection.OpenAsync(ct).ConfigureAwait(false); + } + + var dt = new DataTable(); + var readerObj = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + await using (readerObj.ConfigureAwait(false)) + { + dt.Load(readerObj); + } + + // 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 + }); + + _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)); + } + catch (Exception ex) + { + // Never break Jellyfin — if caching fails, let EF Core execute normally. + // Return the original result so the pipeline continues without interception. + _logger?.LogWarning(ex, "HomeQueryCache execution failed — falling back to direct DB query."); + return default; // default(InterceptionResult) = no suppression + } + } + + // ───────────────────────────────────────────────────────────────────────── + // ORDER BY RANDOM() → TABLESAMPLE rewrite + // ───────────────────────────────────────────────────────────────────────── + + private static bool HasOrderByRandom(DbCommand command) + { + return command.CommandText.Contains("RANDOM()", StringComparison.OrdinalIgnoreCase); + } + + private async ValueTask> RewriteRandomAndExecuteAsync( + DbCommand command, + CancellationToken ct) + { + try + { + var rewritten = RewriteRandomSql(command.CommandText); + if (rewritten is null) + { + return default; // let EF Core run the original + } + + // rewritten is produced entirely by RewriteRandomSql from EF Core SQL, + // never from user input. + using var newCmd = command.Connection!.CreateCommand(); + SetRewrittenCommandText(newCmd, rewritten); + newCmd.Transaction = command.Transaction; + foreach (DbParameter p in command.Parameters) + { + var copy = newCmd.CreateParameter(); + copy.ParameterName = p.ParameterName; + copy.Value = p.Value; + copy.DbType = p.DbType; + newCmd.Parameters.Add(copy); + } + + if (newCmd.Connection!.State != ConnectionState.Open) + { + await newCmd.Connection.OpenAsync(ct).ConfigureAwait(false); + } + + var dt = new DataTable(); + var readerObj = await newCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + await using (readerObj.ConfigureAwait(false)) + { + dt.Load(readerObj); + } + + _logger?.LogDebug("HomeQueryCache RANDOM rewritten with TABLESAMPLE: {Rows} rows", dt.Rows.Count); + + return InterceptionResult.SuppressWithResult(new DataTableReader(dt)); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "ORDER BY RANDOM() rewrite failed — falling back to original query."); + return default; + } + } + + /// + /// Rewrites ORDER BY RANDOM() to use TABLESAMPLE BERNOULLI, which + /// samples pages at the storage level (O(1)) instead of assigning a random float + /// to every row and sorting (O(n log n)). Only transforms the first FROM clause + /// on a known table; falls back to null if the SQL structure is ambiguous. + /// + private static string? RewriteRandomSql(string sql) + { + // Guard: only rewrite single-table queries on known tables to avoid breaking JOINs + var tableMatch = Regex.Match( + sql, + @"FROM\s+""(BaseItems|UserData|ItemValues|ItemValuesMap|PeopleBaseItemMap|Peoples)""", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + if (!tableMatch.Success) + { + return null; + } + + // Strip ORDER BY RANDOM() — TABLESAMPLE provides the randomness + var rewritten = Regex.Replace( + sql, + @"\bORDER\s+BY\s+RANDOM\s*\(\s*\)", + string.Empty, + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + // Inject TABLESAMPLE after the matched table name + var table = tableMatch.Value; // e.g. FROM "BaseItems" + var replacement = table + " TABLESAMPLE BERNOULLI(30)"; // 30 % sample ≈ ~1500 of 5000 + var idx = rewritten.IndexOf(table, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return null; + } + + return string.Concat(rewritten.AsSpan(0, idx), replacement, rewritten.AsSpan(idx + table.Length)); + } + + /// Purges all cached entries. Call after a library scan or metadata refresh. + public void Purge() + { + _cache.Compact(1.0); + _logger?.LogDebug("HomeQueryCache purged after external data change."); + } + + // CA2100: sql comes from RewriteRandomSql which only transforms EF Core-generated SQL, never from user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "sql is produced by RewriteRandomSql from EF Core-generated SQL, never from raw user HTTP input.")] + private static void SetRewrittenCommandText(System.Data.Common.DbCommand cmd, string sql) + => cmd.CommandText = sql; +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs b/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs new file mode 100644 index 0000000..f8a8f02 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Low-latency search service that bypasses EF Core and queries PostgreSQL directly +/// using pg_trgm trigram similarity. Target: < 15 ms round-trip for up to 8 results. +/// Falls back to plain ILIKE when pg_trgm is unavailable. +/// +public sealed class InstantSearchService +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public InstantSearchService(ILogger logger) + { + _logger = logger; + } + + // ───────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Searches BaseItems for media matching and returns + /// a minimal, ranked result set. Uses pg_trgm when available. + /// + /// The user-typed search term (raw, not sanitised beyond trimming). + /// Active Npgsql connection string. + /// Maximum results to return (default 8, max 50). + /// Optional comma-separated list of Jellyfin media types to filter by. + /// Cancellation token. + /// List of ranked search results. + public async Task> SearchAsync( + string term, + string connectionString, + int limit = 8, + string? mediaTypes = null, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(term) || term.Length < 1) + { + return new List(); + } + + limit = Math.Clamp(limit, 1, 50); + term = term.Trim(); + + // MaxAutoPrepare=0 prevents plan cache pollution in the shared pool. + // Pooling remains ON (default) so TCP+TLS is reused across keystrokes. + var builder = new NpgsqlConnectionStringBuilder(connectionString) + { + MaxAutoPrepare = 0, + CommandTimeout = 1, + }; + + using var conn = new NpgsqlConnection(builder.ConnectionString); + try + { + await conn.OpenAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "InstantSearch: could not open database connection."); + return new List(); + } + + return PostgresDatabaseProvider.TrgmAvailable + ? await SearchWithTrgmAsync(conn, term, limit, mediaTypes, ct).ConfigureAwait(false) + : await SearchWithIlikeAsync(conn, term, limit, mediaTypes, ct).ConfigureAwait(false); + } + + // ───────────────────────────────────────────────────────────────────────── + // pg_trgm path — ranked, uses GIN index + // ───────────────────────────────────────────────────────────────────────── + + private async Task> SearchWithTrgmAsync( + NpgsqlConnection conn, + string term, + int limit, + string? mediaTypes, + CancellationToken ct) + { + // word_similarity is better than similarity for prefix/partial matches: + // word_similarity('bat', 'Batman Begins') ≈ 1.0 + // similarity('bat', 'Batman Begins') ≈ 0.18 + // ILIKE activates the GIN index; word_similarity ranks from that candidate set. + var mediaTypeFilter = BuildMediaTypeFilter(mediaTypes); + var sql = $""" + SELECT + "Id", + "Name", + "Type", + "ProductionYear", + "Artists", + "Album", + "SeriesName", + GREATEST( + word_similarity(@term, coalesce("Name", '')), + word_similarity(@term, coalesce("OriginalTitle",'')), + word_similarity(@term, coalesce("Album", '')), + word_similarity(@term, coalesce("Artists", '')), + word_similarity(@term, coalesce("SeriesName", '')) + ) AS relevance + FROM "BaseItems" + WHERE "IsVirtualItem" = false + AND ( + "Name" ILIKE @like + OR "OriginalTitle" ILIKE @like + OR "Album" ILIKE @like + OR "Artists" ILIKE @like + OR "SeriesName" ILIKE @like + ) + {mediaTypeFilter} + ORDER BY relevance DESC, "DateCreated" DESC NULLS LAST + LIMIT @limit; + """; + + return await ExecuteSearchAsync(conn, sql, term, limit, ct).ConfigureAwait(false); + } + + // ───────────────────────────────────────────────────────────────────────── + // Fallback path — plain ILIKE, no trgm ordering + // ───────────────────────────────────────────────────────────────────────── + + private async Task> SearchWithIlikeAsync( + NpgsqlConnection conn, + string term, + int limit, + string? mediaTypes, + CancellationToken ct) + { + var mediaTypeFilter = BuildMediaTypeFilter(mediaTypes); + var sql = $""" + SELECT + "Id", + "Name", + "Type", + "ProductionYear", + "Artists", + "Album", + "SeriesName", + CASE WHEN lower("Name") = lower(@term) THEN 1.0 ELSE 0.5 END AS relevance + FROM "BaseItems" + WHERE "IsVirtualItem" = false + AND ( + "Name" ILIKE @like + OR "OriginalTitle" ILIKE @like + OR "Album" ILIKE @like + OR "Artists" ILIKE @like + OR "SeriesName" ILIKE @like + ) + {mediaTypeFilter} + ORDER BY relevance DESC, "DateCreated" DESC NULLS LAST + LIMIT @limit; + """; + + return await ExecuteSearchAsync(conn, sql, term, limit, ct).ConfigureAwait(false); + } + + // ───────────────────────────────────────────────────────────────────────── + // Shared execution + mapping + // ───────────────────────────────────────────────────────────────────────── + + private async Task> ExecuteSearchAsync( + NpgsqlConnection conn, + string sql, + string term, + int limit, + CancellationToken ct) + { + var results = new List(limit); + try + { + // sql comes from BuildSearchSql (hardcoded templates), not user input (CA2100). + using var cmd = CreateSearchCommand(conn, sql); + cmd.Parameters.AddWithValue("term", term); + cmd.Parameters.AddWithValue("like", $"%{EscapeLike(term)}%"); + cmd.Parameters.AddWithValue("limit", limit); + + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + results.Add(await MapRowAsync(reader, ct).ConfigureAwait(false)); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "InstantSearch query failed."); + } + + return results; + } + + private static async Task MapRowAsync(NpgsqlDataReader reader, CancellationToken ct) + => new InstantSearchResult( + Id: reader.GetGuid(0), + Name: await reader.IsDBNullAsync(1, ct).ConfigureAwait(false) ? null : reader.GetString(1), + Type: await reader.IsDBNullAsync(2, ct).ConfigureAwait(false) ? null : reader.GetString(2), + Year: await reader.IsDBNullAsync(3, ct).ConfigureAwait(false) ? null : reader.GetInt32(3), + Artists: await reader.IsDBNullAsync(4, ct).ConfigureAwait(false) ? null : reader.GetString(4), + Album: await reader.IsDBNullAsync(5, ct).ConfigureAwait(false) ? null : reader.GetString(5), + SeriesName: await reader.IsDBNullAsync(6, ct).ConfigureAwait(false) ? null : reader.GetString(6), + Relevance: await reader.IsDBNullAsync(7, ct).ConfigureAwait(false) + ? 0.0 + : Convert.ToDouble(reader.GetValue(7), CultureInfo.InvariantCulture)); + + private static string BuildMediaTypeFilter(string? mediaTypes) + { + if (string.IsNullOrWhiteSpace(mediaTypes)) + { + return string.Empty; + } + + // e.g. "Movie,Series,Audio" → AND "MediaType" IN ('Movie','Series','Audio') + var types = mediaTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (types.Length == 0) + { + return string.Empty; + } + + // Sanitise: only allow alphanumeric + var safe = new System.Text.StringBuilder(); + safe.Append("AND \"MediaType\" IN ("); + for (var i = 0; i < types.Length; i++) + { + if (i > 0) + { + safe.Append(','); + } + + // Escape single-quote by doubling it (SQL standard, parameter is sanitised above) + var t = types[i].Replace("'", "''", StringComparison.Ordinal); + safe.Append(CultureInfo.InvariantCulture, $"'{t}'"); + } + + safe.Append(')'); + return safe.ToString(); + } + + /// Escapes LIKE special characters in the user-supplied term. + private static string EscapeLike(string term) + => term + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "\\%", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal); + + // sql is produced by BuildSearchSql from hardcoded templates. User-supplied values are always + // passed as parameters (term, like, limit) — never interpolated into the SQL string. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "sql is built from hardcoded templates; user values are parameterized via NpgsqlCommand.Parameters.")] + private static NpgsqlCommand CreateSearchCommand(NpgsqlConnection conn, string sql) + => new NpgsqlCommand(sql, conn); +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs new file mode 100644 index 0000000..f90f65a --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs @@ -0,0 +1,483 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Backup and restore operations using pg_dump / psql. +/// General maintenance methods are in . +/// +public sealed class MaintenanceBackupService +{ + // Allowed executable base names — only these are accepted as custom paths (CA3006). + private static readonly string[] AllowedPgExecutables = { "pg_dump", "pg_dumpall", "psql", "pg_restore" }; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public MaintenanceBackupService(ILogger logger) => _logger = logger; + + // ── Backup ──────────────────────────────────────────────────────────────── + + /// + /// Creates a database backup using pg_dump. + /// Optionally compresses the output into a ZIP. + /// Returns the final backup file path. + /// + /// PostgreSQL connection string for the source database. + /// 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. + /// Cancellation token. + /// Absolute path to the created backup file. + public async Task CreateBackupAsync( + string connectionString, + string outputDirectory, + bool compress, + string? pgBinPath, + CancellationToken ct = default) + { + var cs = new NpgsqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(cs.Database)) + { + throw new InvalidOperationException("Connection string must contain Database."); + } + + // Canonicalize to prevent path traversal (CA3003) + var safeOutputDirectory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(safeOutputDirectory); + + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + var safeDbName = MaintenanceService.SanitizeFileName(cs.Database ?? string.Empty); + var sqlFilePath = Path.Combine(safeOutputDirectory, $"{safeDbName}_{timestamp}.sql"); + + // Resolve + validate the executable path; name must be in AllowedPgExecutables (CA3006) + var safeExecutable = ResolveExecutable(pgBinPath, "pg_dump"); + var args = BuildPgDumpArguments(cs, sqlFilePath); + + _logger.LogInformation("Starting PostgreSQL backup with pg_dump to {Path}", sqlFilePath); + PostgresLog.Warn($"[Backup] INICIO: pg_dump → {sqlFilePath}"); + + var psi = CreateProcessStartInfo(safeExecutable, args); + + if (!string.IsNullOrWhiteSpace(cs.Password)) + { + psi.Environment["PGPASSWORD"] = cs.Password; + } + + using var process = new Process { StartInfo = psi }; + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start pg_dump process."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + var stdOut = await stdoutTask.ConfigureAwait(false); + var stdErr = await stderrTask.ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var detail = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr; + throw new InvalidOperationException( + $"pg_dump failed (exit {process.ExitCode}). {detail}".Trim()); + } + + if (!SafeFileExists(sqlFilePath)) + { + throw new InvalidOperationException("Backup completed but SQL output file was not created."); + } + + if (!compress) + { + var sqlSize = MaintenanceService.FormatBytes(GetFileSize(sqlFilePath)); + _logger.LogInformation("PostgreSQL backup completed at {Path} ({Size})", sqlFilePath, sqlSize); + PostgresLog.Warn($"[Backup] COMPLETADO: {sqlFilePath} ({sqlSize})"); + return sqlFilePath; + } + + var zipPath = sqlFilePath + ".zip"; + if (SafeFileExists(zipPath)) + { + SafeFileDelete(zipPath); + } + + using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(sqlFilePath, Path.GetFileName(sqlFilePath), CompressionLevel.Optimal); + } + + 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})"); + return zipPath; + } + + // ── Restore ─────────────────────────────────────────────────────────────── + + /// + /// Restores a PostgreSQL backup (.sql or .zip) using psql. + /// Returns the SQL file path used for restoration. + /// + /// PostgreSQL connection string for the target database. + /// Path to the .sql or .zip backup file. + /// Optional directory containing pg_dump, psql, etc. Leave null/empty to auto-detect or rely on PATH. + /// When , uses --single-transaction. + /// Cancellation token. + /// Absolute path to the SQL file used for restoration. + public async Task RestoreBackupAsync( + string connectionString, + string backupFilePath, + string? pgBinPath, + bool replaceExistingObjects, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(backupFilePath)) + { + throw new InvalidOperationException("Backup file path is required."); + } + + // Canonicalize input path — prevents path traversal (CA3003) + backupFilePath = MaintenanceService.ValidateBackupFilePath(backupFilePath); + + if (!SafeFileExists(backupFilePath)) + { + throw new FileNotFoundException("Backup file not found.", backupFilePath); + } + + var cs = new NpgsqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(cs.Database)) + { + throw new InvalidOperationException("Connection string must contain Database."); + } + + string? tempDir = null; + var sqlFilePath = backupFilePath; + + try + { + 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); + } + + if (!SafeFileExists(sqlFilePath) || !sqlFilePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Restore requires a .sql backup file."); + } + + // Resolve + validate before assigning to ProcessStartInfo (CA3006) + var safeRestoreExe = ResolveExecutable(pgBinPath, "psql"); + var args = BuildPsqlRestoreArguments(cs, sqlFilePath, replaceExistingObjects); + + _logger.LogWarning( + "Starting PostgreSQL restore from {Backup}. ReplaceExistingObjects={Replace}", + backupFilePath, + replaceExistingObjects); + var sourceSize = MaintenanceService.FormatBytes(GetFileSize(backupFilePath)); + PostgresLog.Warn($"[Restore] INICIO: {backupFilePath} ({sourceSize}) replaceExisting={replaceExistingObjects}"); + + var psi = CreateProcessStartInfo(safeRestoreExe, args); + + if (!string.IsNullOrWhiteSpace(cs.Password)) + { + psi.Environment["PGPASSWORD"] = cs.Password; + } + + using var process = new Process { StartInfo = psi }; + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start psql restore process."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + var stdOut = await stdoutTask.ConfigureAwait(false); + var stdErr = await stderrTask.ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var detail = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr; + _logger.LogWarning("psql restore exited with code {Code}: {Detail}", process.ExitCode, detail); + } + + return sqlFilePath; + } + finally + { + if (tempDir is not null && Directory.Exists(tempDir)) + { + try + { + Directory.Delete(tempDir, recursive: true); + } + catch (IOException) + { + // best-effort cleanup — ignore + } + } + } + } + + // ── Process helpers (CA3003/CA3006: validate executables against allowed names) ────── + + /// + /// Resolves a PostgreSQL client executable (pg_dump, psql, …) from a bin directory. + /// + /// If is provided, we look for <pgBinPath>/<name>[.exe]. + /// Otherwise we search well-known OS installation directories. + /// If still not found, we fall back to the bare name and rely on PATH. + /// + /// + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "pgBinPath is a user-configured directory validated against AllowedPgExecutables name. Constructed path is canonicalized with Path.GetFullPath.")] + private static string ResolveExecutable(string? pgBinPath, string executableName) + { + if (!AllowedPgExecutables.Contains(executableName, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Executable '{executableName}' is not in the allowed list ({string.Join(", ", AllowedPgExecutables)})."); + } + + // 1. User-configured bin directory takes priority. + if (!string.IsNullOrWhiteSpace(pgBinPath)) + { + var userBin = Path.GetFullPath(pgBinPath); + var candidate = FindInDirectory(userBin, executableName); + if (candidate is not null) + { + return candidate; + } + + // Dir was provided but exe not found there — throw so the user knows the config is wrong. + throw new InvalidOperationException( + $"Executable '{executableName}' not found in configured PgBinPath: {userBin}"); + } + + // 2. Search well-known installation directories (Windows: Program Files; Linux: postgresql version dirs). + var autoFound = FindInKnownLocations(executableName); + if (autoFound is not null) + { + return autoFound; + } + + // 3. Rely on PATH (works on most Linux/macOS installations). + return executableName; + } + + // 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) + { + // Try with .exe extension first (Windows), then bare name (Linux/macOS). + var withExt = Path.Combine(dir, executableName + ".exe"); + if (File.Exists(withExt)) + { + return withExt; + } + + var bare = Path.Combine(dir, executableName); + if (File.Exists(bare)) + { + return bare; + } + + return null; + } + + /// + /// Searches well-known PostgreSQL installation directories for the given executable. + /// Checks newest version first. Returns when not found. + /// + [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; + } + + var pgRoot = Path.Combine(pf, "PostgreSQL"); + if (!Directory.Exists(pgRoot)) + { + continue; + } + + var versionDirs = Directory.GetDirectories(pgRoot); + 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; + } + + // ── Linux: /usr/lib/postgresql//bin/ (Debian/Ubuntu packages) + const string linuxPgRoot = "/usr/lib/postgresql"; + if (Directory.Exists(linuxPgRoot)) + { + 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; + } + } + } + + // ── macOS (Homebrew): /opt/homebrew/opt/postgresql@/bin/ + const string brewRoot = "/opt/homebrew/opt"; + if (Directory.Exists(brewRoot)) + { + 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) + { + return found; + } + } + } + + return null; + } + + private static string BuildPgDumpArguments(NpgsqlConnectionStringBuilder cs, string outputPath) + { + // Use string.Join / string concatenation to avoid CA1305 (culture-sensitive Append) + var parts = new[] + { + string.Concat("--host=", QuoteArg(cs.Host ?? "localhost")), + string.Format(System.Globalization.CultureInfo.InvariantCulture, "--port={0}", cs.Port), + string.Concat("--username=", QuoteArg(cs.Username ?? "postgres")), + string.Concat("--dbname=", QuoteArg(cs.Database ?? string.Empty)), + string.Concat("--file=", QuoteArg(outputPath)), + "--format=plain --no-password", + }; + return string.Join(" ", parts); + } + + private static string BuildPsqlRestoreArguments( + NpgsqlConnectionStringBuilder cs, + string sqlFilePath, + bool replaceExistingObjects) + { + var parts = new System.Collections.Generic.List + { + string.Concat("--host=", QuoteArg(cs.Host ?? "localhost")), + string.Format(System.Globalization.CultureInfo.InvariantCulture, "--port={0}", cs.Port), + string.Concat("--username=", QuoteArg(cs.Username ?? "postgres")), + string.Concat("--dbname=", QuoteArg(cs.Database ?? string.Empty)), + }; + + if (replaceExistingObjects) + { + parts.Add("--single-transaction"); + } + + parts.Add(string.Concat("--file=", QuoteArg(sqlFilePath))); + parts.Add("--no-password"); + return string.Join(" ", parts); + } + + private static string QuoteArg(string value) + => '"' + value.Replace("\"", "\\\"", StringComparison.Ordinal) + '"'; + + // ── Process start helper (CA3006) ───────────────────────────────────────── + // executable has been validated against AllowedPgExecutables before reaching here. + [SuppressMessage("Security", "CA3006:Review code for process command injection vulnerabilities", Justification = "executable is validated against AllowedPgExecutables whitelist and File.Exists. args are built from NpgsqlConnectionStringBuilder (server-trusted) + QuoteArg-escaped output path.")] + private static ProcessStartInfo CreateProcessStartInfo(string executable, string arguments) + => new ProcessStartInfo + { + FileName = executable, + Arguments = arguments, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // ── Isolated file I/O helpers (CA3003) ──────────────────────────────────── + // All paths reaching here have been canonicalized with Path.GetFullPath or + // constructed programmatically (safeOutputDirectory + timestamp filename). + + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths are canonicalized via Path.GetFullPath or built from trusted internal values before reaching this method.")] + private static bool SafeFileExists(string path) + => File.Exists(path); + + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths are canonicalized via Path.GetFullPath or built from trusted internal values before reaching this method.")] + private static void SafeFileDelete(string path) + => File.Delete(path); + + // Path has been canonicalized by Path.GetFullPath before reaching here (CA3003). + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Path is canonicalized with Path.GetFullPath or constructed from trusted internal values before this call.")] + private static long GetFileSize(string path) + => new FileInfo(path).Length; +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs index 8cc4700..e24fe7a 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs @@ -1,55 +1,51 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Jellyfin.Database.Providers.Postgres.Services.Models; using Microsoft.Extensions.Logging; using Npgsql; namespace Jellyfin.Database.Providers.Postgres.Services; -/// Table size statistics returned by the maintenance API. -public sealed record TableStats( - string TableName, - long RowCount, - string TotalSize, - string TableSize, - string IndexSize); - /// -/// Provides PostgreSQL maintenance operations: VACUUM ANALYZE, REINDEX, and table statistics. +/// PostgreSQL maintenance: connection tests, VACUUM, REINDEX, stats queries. +/// Backup/restore operations are in . /// public sealed class MaintenanceService { private readonly ILogger _logger; - /// Initializes a new instance of . - public MaintenanceService(ILogger logger) - { - _logger = logger; - } + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public MaintenanceService(ILogger logger) => _logger = logger; - // ───────────────────────────────────────────────────────────────────────── - // Connection test - // ───────────────────────────────────────────────────────────────────────── + // ── Connection helpers ──────────────────────────────────────────────────── /// - /// Tests the connection string and returns on success, - /// or an error message on failure. + /// Tests the connection string. Returns on success or an error message. /// + /// PostgreSQL connection string to test. + /// Cancellation token. + /// on success, or an error message string on failure. public static async Task TestConnectionAsync(string connectionString, CancellationToken ct = default) { try { - await using var pg = new NpgsqlConnection(connectionString); + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand("SELECT version();", pg); + using var cmd = new NpgsqlCommand("SELECT version();", pg); await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - return null; // success — version is logged but not returned + return null; } catch (Exception ex) { @@ -57,81 +53,81 @@ public MaintenanceService(ILogger logger) } } - /// - /// Tests the connection string and returns the PostgreSQL server version string. - /// Throws if the connection fails. - /// + /// Returns the PostgreSQL server version string. + /// public static async Task GetServerVersionAsync(string connectionString, CancellationToken ct = default) { - await using var pg = new NpgsqlConnection(connectionString); + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand("SELECT version();", pg); + using var cmd = new NpgsqlCommand("SELECT version();", pg); return (await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false))?.ToString() ?? "unknown"; } - // ───────────────────────────────────────────────────────────────────────── - // VACUUM ANALYZE - // ───────────────────────────────────────────────────────────────────────── + // ── VACUUM ANALYZE ──────────────────────────────────────────────────────── - /// - /// Runs VACUUM ANALYZE on the database. This reclaims dead tuple storage - /// and refreshes query planner statistics. - /// Note: must be run outside a transaction block (Npgsql handles this automatically - /// when CommandTimeout is set to 0 and no explicit transaction is open). - /// + /// Runs VACUUM ANALYZE on the database. + /// public async Task VacuumAnalyzeAsync(string connectionString, CancellationToken ct = default) { _logger.LogInformation("Starting VACUUM ANALYZE..."); - await using var pg = new NpgsqlConnection(connectionString); + PostgresLog.Warn("[Vacuum] INICIO: VACUUM ANALYZE en toda la base de datos..."); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - // VACUUM cannot run inside a transaction — use command timeout 0 for long-running ops - await using var cmd = new NpgsqlCommand("VACUUM ANALYZE;", pg) { CommandTimeout = 0 }; + using var cmd = new NpgsqlCommand("VACUUM ANALYZE;", pg) { CommandTimeout = 0 }; await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + 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."); } - // ───────────────────────────────────────────────────────────────────────── - // REINDEX - // ───────────────────────────────────────────────────────────────────────── + // ── REINDEX ─────────────────────────────────────────────────────────────── - /// Runs REINDEX DATABASE to rebuild all indexes. + /// Runs REINDEX DATABASE CONCURRENTLY. + /// public async Task ReindexAsync(string connectionString, CancellationToken ct = default) { _logger.LogInformation("Starting REINDEX DATABASE..."); - await using var pg = new NpgsqlConnection(connectionString); + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - await using var dbNameCmd = new NpgsqlCommand("SELECT current_database();", pg); - var dbNameObj = await dbNameCmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - var dbName = dbNameObj?.ToString(); + + string dbName; + using (var dbNameCmd = new NpgsqlCommand("SELECT current_database();", pg)) + { + dbName = (await dbNameCmd.ExecuteScalarAsync(ct).ConfigureAwait(false))?.ToString() ?? string.Empty; + } + if (string.IsNullOrWhiteSpace(dbName)) { throw new InvalidOperationException("Unable to resolve current PostgreSQL database name for REINDEX."); } - var sql = $"REINDEX DATABASE CONCURRENTLY {QuoteIdentifier(dbName)};"; - await using var cmd = new NpgsqlCommand(sql, pg) { CommandTimeout = 0 }; + // 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}..."); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using var cmd = CreateReindexCommand(pg, dbName); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + 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."); } - private static string QuoteIdentifier(string input) - => $"\"{input.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; - - // ───────────────────────────────────────────────────────────────────────── - // Table stats - // ───────────────────────────────────────────────────────────────────────── + // ── Table statistics ────────────────────────────────────────────────────── - /// - /// Returns size and row-count statistics for all user tables in the given schema. - /// + /// Returns size and row-count statistics for all user tables in the given schema. + /// PostgreSQL connection string. + /// Schema to query. Defaults to public. + /// Cancellation token. + /// List of per-table size and row-count statistics. public static async Task> GetTableStatsAsync( string connectionString, string schema = "public", CancellationToken ct = default) { - await using var pg = new NpgsqlConnection(connectionString); - await pg.OpenAsync(ct).ConfigureAwait(false); - - // reltuples is an estimate from pg_class; use it as-is (same as pgAdmin). - await using var cmd = new NpgsqlCommand(@" + const string tableStatsSql = @" SELECT c.relname AS table_name, c.reltuples::bigint AS row_count, @@ -146,11 +142,14 @@ FROM pg_class c WHERE n.nspname = @schema AND c.relkind = 'r' ORDER BY pg_total_relation_size(c.oid) DESC - LIMIT 100;", pg); - cmd.Parameters.AddWithValue("schema", schema); + LIMIT 100;"; var result = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(tableStatsSql, pg); + cmd.Parameters.AddWithValue("schema", schema); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await reader.ReadAsync(ct).ConfigureAwait(false)) { result.Add(new TableStats( @@ -164,346 +163,274 @@ ORDER BY pg_total_relation_size(c.oid) DESC return result; } - // ───────────────────────────────────────────────────────────────────────── - // Connection count - // ───────────────────────────────────────────────────────────────────────── - /// Returns the current number of active connections to the database. + /// public static async Task GetConnectionCountAsync(string connectionString, CancellationToken ct = default) { - await using var pg = new NpgsqlConnection(connectionString); + const string sql = "SELECT COUNT(*) FROM pg_stat_activity WHERE datname = current_database();"; + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand( - "SELECT COUNT(*) FROM pg_stat_activity WHERE datname = current_database();", pg); + using var cmd = new NpgsqlCommand(sql, pg); var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); return Convert.ToInt32(result, CultureInfo.InvariantCulture); } - // ───────────────────────────────────────────────────────────────────────── - // Database size - // ───────────────────────────────────────────────────────────────────────── - /// Returns the total size of the current PostgreSQL database as a human-readable string. + /// public static async Task GetDatabaseSizeAsync(string connectionString, CancellationToken ct = default) { - await using var pg = new NpgsqlConnection(connectionString); + const string sql = "SELECT pg_size_pretty(pg_database_size(current_database()));"; + using var pg = new NpgsqlConnection(connectionString); await pg.OpenAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand( - "SELECT pg_size_pretty(pg_database_size(current_database()));", pg); + using var cmd = new NpgsqlCommand(sql, pg); return (await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false))?.ToString() ?? "unknown"; } - // ───────────────────────────────────────────────────────────────────────── - // Backup - // ───────────────────────────────────────────────────────────────────────── + // ── GIN index status ────────────────────────────────────────────────────── /// - /// Creates a database backup using pg_dump in plain SQL format. - /// Optionally compresses the resulting SQL file into a ZIP archive. - /// Returns the final backup file path. + /// Returns the names and sizes of all GIN indexes in the public schema + /// created by this plugin (names ending in _gin_trgm). /// - public async Task CreateBackupAsync( - string connectionString, - string outputDirectory, - bool compress, - string? pgDumpPath, - CancellationToken ct = default) + /// + public static async Task> GetGinIndexStatusAsync( + string connectionString, CancellationToken ct = default) { - var cs = new NpgsqlConnectionStringBuilder(connectionString); - if (string.IsNullOrWhiteSpace(cs.Database)) + const string ginSql = @" + SELECT + c.relname AS index_name, + t.relname AS table_name, + pg_size_pretty(pg_relation_size(c.oid)) AS index_size, + ix.indisvalid AS is_valid + FROM pg_class c + JOIN pg_index ix ON ix.indexrelid = c.oid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'i' + AND c.relname LIKE '%_gin_trgm' + ORDER BY t.relname, c.relname;"; + + var result = new List(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new NpgsqlCommand(ginSql, pg); + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) { - throw new InvalidOperationException("Connection string must contain Database."); + result.Add(new GinIndexInfo( + IndexName: reader.GetString(0), + TableName: reader.GetString(1), + IndexSize: reader.GetString(2), + IsValid: reader.GetBoolean(3))); } - Directory.CreateDirectory(outputDirectory); - - var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); - var databaseName = cs.Database ?? string.Empty; - var safeDbName = SanitizeFileName(databaseName); - var sqlFilePath = Path.Combine(outputDirectory, $"{safeDbName}_{timestamp}.sql"); - - var executable = ResolvePgDumpExecutable(pgDumpPath); - var args = BuildPgDumpArguments(cs, sqlFilePath); - - _logger.LogInformation("Starting PostgreSQL backup with pg_dump to {Path}", sqlFilePath); + return result; + } - var psi = new ProcessStartInfo - { - FileName = executable, - Arguments = args, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; + // ── Internal helpers ────────────────────────────────────────────────────── - if (!string.IsNullOrWhiteSpace(cs.Password)) - { - psi.Environment["PGPASSWORD"] = cs.Password; - } + internal static string QuoteIdentifier(string input) + => $"\"{input.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; - using var process = new Process { StartInfo = psi }; - if (!process.Start()) + internal static string ValidateBackupFilePath(string path) + { + if (!Path.IsPathFullyQualified(path)) { - throw new InvalidOperationException("Failed to start pg_dump process."); + throw new InvalidOperationException( + $"Backup file path must be an absolute path. Received: {path}"); } - var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); - var stderrTask = process.StandardError.ReadToEndAsync(ct); - - await process.WaitForExitAsync(ct).ConfigureAwait(false); - var stdOut = await stdoutTask.ConfigureAwait(false); - var stdErr = await stderrTask.ConfigureAwait(false); + return Path.GetFullPath(path); + } - if (process.ExitCode != 0) + internal static string SanitizeFileName(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) { - var detail = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr; - throw new InvalidOperationException($"pg_dump failed with exit code {process.ExitCode}. {detail}".Trim()); + value = value.Replace(c, '_'); } - if (!File.Exists(sqlFilePath)) - { - throw new InvalidOperationException("Backup completed but SQL output file was not created."); - } + return value; + } - if (!compress) + internal static string FormatBytes(long bytes) + { + const long kb = 1024; + const long mb = kb * 1024; + const long gb = mb * 1024; + if (bytes >= gb) { - _logger.LogInformation("PostgreSQL backup completed at {Path}", sqlFilePath); - return sqlFilePath; + return string.Format(CultureInfo.InvariantCulture, "{0:F1} GB", (double)bytes / gb); } - var zipPath = sqlFilePath + ".zip"; - if (File.Exists(zipPath)) + if (bytes >= mb) { - File.Delete(zipPath); + return string.Format(CultureInfo.InvariantCulture, "{0:F1} MB", (double)bytes / mb); } - using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + if (bytes >= kb) { - archive.CreateEntryFromFile(sqlFilePath, Path.GetFileName(sqlFilePath), CompressionLevel.Optimal); + return string.Format(CultureInfo.InvariantCulture, "{0:F1} KB", (double)bytes / kb); } - File.Delete(sqlFilePath); - _logger.LogInformation("PostgreSQL backup completed at {Path}", zipPath); - return zipPath; + return string.Format(CultureInfo.InvariantCulture, "{0} B", bytes); } + // ── Backup listing ──────────────────────────────────────────────────────── + /// - /// Restores a PostgreSQL backup file (.sql or .zip containing .sql) using psql. - /// Returns the SQL file path used for restoration. + /// Returns all .sql and .zip backup files in , + /// sorted newest-first. Returns an empty list when the directory does not exist. /// - public async Task RestoreBackupAsync( - string connectionString, - string backupFilePath, - string? pgRestorePath, - bool replaceExistingObjects, - CancellationToken ct = default) + /// Directory to scan for backup files. + /// Ordered list of records. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "backupDirectory comes from server-side plugin configuration, not from user HTTP input.")] + public static System.Collections.Generic.IReadOnlyList ListBackups(string backupDirectory) { - if (string.IsNullOrWhiteSpace(backupFilePath)) - { - throw new InvalidOperationException("Backup file path is required."); - } - - if (!File.Exists(backupFilePath)) + if (!Directory.Exists(backupDirectory)) { - throw new FileNotFoundException("Backup file not found.", backupFilePath); + return System.Array.Empty(); } - var cs = new NpgsqlConnectionStringBuilder(connectionString); - if (string.IsNullOrWhiteSpace(cs.Database)) - { - throw new InvalidOperationException("Connection string must contain Database."); - } - - string? tempDir = null; - var sqlFilePath = backupFilePath; - - try - { - 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."); - } - - sqlFilePath = Path.Combine(tempDir, Path.GetFileName(sqlEntry.FullName)); - sqlEntry.ExtractToFile(sqlFilePath, overwrite: true); - } - - if (!File.Exists(sqlFilePath) || !sqlFilePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("Restore requires a .sql backup file."); - } - - var executable = ResolvePsqlExecutable(pgRestorePath); - var args = BuildPsqlRestoreArguments(cs, sqlFilePath, replaceExistingObjects); - - _logger.LogWarning( - "Starting PostgreSQL restore from {Backup}. ReplaceExistingObjects={ReplaceExistingObjects}", - backupFilePath, - replaceExistingObjects); - - var psi = new ProcessStartInfo - { - FileName = executable, - Arguments = args, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (!string.IsNullOrWhiteSpace(cs.Password)) - { - psi.Environment["PGPASSWORD"] = cs.Password; - } - - using var process = new Process { StartInfo = psi }; - if (!process.Start()) - { - throw new InvalidOperationException("Failed to start psql restore process."); - } - - var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); - var stderrTask = process.StandardError.ReadToEndAsync(ct); - - await process.WaitForExitAsync(ct).ConfigureAwait(false); - var stdOut = await stdoutTask.ConfigureAwait(false); - var stdErr = await stderrTask.ConfigureAwait(false); - - if (process.ExitCode != 0) - { - var detail = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr; - throw new InvalidOperationException($"psql restore failed with exit code {process.ExitCode}. {detail}".Trim()); - } - - _logger.LogWarning("PostgreSQL restore completed from {Backup}", backupFilePath); - return sqlFilePath; - } - finally - { - if (!string.IsNullOrWhiteSpace(tempDir) && Directory.Exists(tempDir)) + var files = Directory.GetFiles(backupDirectory) + .Where(static f => + f.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".sql.zip", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + .Select(static f => { - try - { - Directory.Delete(tempDir, recursive: true); - } - catch - { - // Best-effort cleanup. - } - } - } + var info = new FileInfo(f); + return new BackupFileInfo( + Name: info.Name, + Path: info.FullName, + SizeBytes: info.Length, + SizeFormatted: FormatBytes(info.Length), + CreatedAtUtc: info.LastWriteTimeUtc); + }) + .OrderByDescending(static b => b.CreatedAtUtc) + .ToList(); + + return files; } - private static string BuildPgDumpArguments(NpgsqlConnectionStringBuilder cs, string outputFile) - { - var sb = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(cs.Host)) - { - sb.Append(" --host ").Append(QuoteArg(cs.Host)); - } - - if (cs.Port > 0) - { - sb.Append(" --port ").Append(cs.Port.ToString(CultureInfo.InvariantCulture)); - } - - if (!string.IsNullOrWhiteSpace(cs.Username)) - { - sb.Append(" --username ").Append(QuoteArg(cs.Username)); - } - - sb.Append(" --format=plain --no-owner --no-privileges"); - sb.Append(" --file ").Append(QuoteArg(outputFile)); - sb.Append(' ').Append(QuoteArg(cs.Database ?? string.Empty)); + // ── Backup / Restore (delegates to MaintenanceBackupService) ───────────── - return sb.ToString().Trim(); + /// + /// Creates a database backup using pg_dump. + /// Delegates to . + /// + /// PostgreSQL connection string. + /// 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. + /// Cancellation token. + /// Absolute path to the created backup file. + public Task CreateBackupAsync( + string connectionString, + string outputDirectory, + bool compress, + string? pgBinPath, + CancellationToken ct = default) + { + return new MaintenanceBackupService(_logger).CreateBackupAsync(connectionString, outputDirectory, compress, pgBinPath, ct); } - private static string ResolvePgDumpExecutable(string? configuredPath) + /// + /// Restores a PostgreSQL backup using psql. + /// Delegates to . + /// + /// PostgreSQL connection string. + /// Path to the .sql or .zip backup file. + /// Optional directory containing pg_dump, psql, etc. Leave null/empty to auto-detect or rely on PATH. + /// When , uses --single-transaction. + /// Cancellation token. + /// Absolute path to the SQL file used for restoration. + public Task RestoreBackupAsync( + string connectionString, + string backupFilePath, + string? pgBinPath, + bool replaceExistingObjects, + CancellationToken ct = default) { - if (!string.IsNullOrWhiteSpace(configuredPath)) - { - return configuredPath; - } - - return OperatingSystem.IsWindows() ? "pg_dump.exe" : "pg_dump"; + return new MaintenanceBackupService(_logger).RestoreBackupAsync(connectionString, backupFilePath, pgBinPath, replaceExistingObjects, ct); } - private static string ResolvePsqlExecutable(string? configuredPath) + // ── Truncate all tables (fresh migration support) ───────────────────────── + + /// + /// Truncates all Jellyfin data tables in PostgreSQL using RESTART IDENTITY CASCADE. + /// This is the correct way to prepare PostgreSQL for a clean re-migration from SQLite. + /// The schema is preserved — only data is removed. + /// + /// PostgreSQL connection string. + /// Cancellation token. + /// Summary with tables affected and row counts before truncation. + public static async Task TruncateAllTablesAsync( + string connectionString, CancellationToken ct = default) { - if (!string.IsNullOrWhiteSpace(configuredPath)) + // FK-safe order: children before parents so CASCADE is not required per-table. + // The single TRUNCATE ... CASCADE at the end handles any remaining FK chains. + var tables = new[] { - return configuredPath; - } + "HomeSection", "AccessSchedules", "ImageInfos", "ItemDisplayPreferences", + "CustomItemDisplayPreferences", "DisplayPreferences", "Devices", "DeviceOptions", + "ApiKeys", "UserData", "Preferences", "Permissions", + "PeopleBaseItemMap", "ItemValuesMap", "ItemValues", + "MediaSegments", "MediaStreamInfos", "AttachmentStreamInfos", + "TrickplayInfos", "KeyframeData", "Chapters", + "BaseItemTrailerTypes", "BaseItemMetadataFields", "BaseItemProviders", + "BaseItemImageInfos", "AncestorIds", "BaseItems", + "Peoples", "ActivityLogs", "Users", + }; - return OperatingSystem.IsWindows() ? "psql.exe" : "psql"; - } + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); - private static string SanitizeFileName(string value) - { - foreach (var c in Path.GetInvalidFileNameChars()) + // Collect row counts before truncation for the response + var rowsBefore = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var table in tables) { - value = value.Replace(c, '_'); + using var countCmd = CreateCountCommand(pg, table); + var count = Convert.ToInt64( + await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), + CultureInfo.InvariantCulture); + rowsBefore[table] = count; } - return value; + // Single TRUNCATE with CASCADE — fastest, avoids FK ordering issues + using var truncCmd = CreateTruncateAllCommand(pg, tables); + await truncCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + return new TruncateResult( + TablesAffected: tables.Length, + TotalRowsRemoved: rowsBefore.Values.Sum(), + RowCountsBeforeTruncation: rowsBefore); } - private static string QuoteArg(string value) + [SuppressMessage("Security", "CA2100", Justification = "Table names are hardcoded constants, not user input.")] + private static NpgsqlCommand CreateCountCommand(NpgsqlConnection pg, string table) { - return '"' + value.Replace("\"", "\\\"", StringComparison.Ordinal) + '"'; + var sql = string.Concat("SELECT COUNT(*) FROM public.\"", table, "\";"); + return new NpgsqlCommand(sql, pg); } - private static string BuildPsqlRestoreArguments( - NpgsqlConnectionStringBuilder cs, - string sqlFile, - bool replaceExistingObjects) + [SuppressMessage("Security", "CA2100", Justification = "Table names are hardcoded constants, not user input.")] + private static NpgsqlCommand CreateTruncateAllCommand(NpgsqlConnection pg, string[] tables) { - var sb = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(cs.Host)) - { - sb.Append(" --host ").Append(QuoteArg(cs.Host)); - } - - if (cs.Port > 0) - { - sb.Append(" --port ").Append(cs.Port.ToString(CultureInfo.InvariantCulture)); - } - - if (!string.IsNullOrWhiteSpace(cs.Username)) - { - sb.Append(" --username ").Append(QuoteArg(cs.Username)); - } - - sb.Append(" --dbname ").Append(QuoteArg(cs.Database ?? string.Empty)); - sb.Append(" --set ON_ERROR_STOP=1"); - - if (replaceExistingObjects) - { - // Ensure a consistent restore target even if schema objects already exist. - sb.Append(" --command ").Append(QuoteArg("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")); - } + var tableList = string.Join(",", tables.Select(t => string.Concat("public.\"", t, "\""))); + var sql = string.Concat("TRUNCATE TABLE ", tableList, " RESTART IDENTITY CASCADE;"); + return new NpgsqlCommand(sql, pg) { CommandTimeout = 0 }; + } - sb.Append(" --file ").Append(QuoteArg(sqlFile)); + // ── Command factory (CA2100) ────────────────────────────────────────────── - return sb.ToString().Trim(); + // dbName comes from SELECT current_database() — server-controlled, not user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "dbName comes from SELECT current_database(), server-controlled. QuoteIdentifier prevents injection.")] + [SuppressMessage("Security", "CA3001:Review code for SQL injection vulnerabilities", Justification = "dbName comes from SELECT current_database(), server-controlled value. QuoteIdentifier sanitizes it.")] + private static NpgsqlCommand CreateReindexCommand(NpgsqlConnection pg, string dbName) + { + var sql = string.Concat("REINDEX DATABASE CONCURRENTLY ", QuoteIdentifier(dbName), ";"); + return new NpgsqlCommand(sql, pg) { CommandTimeout = 0 }; } } diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs new file mode 100644 index 0000000..dbb67b9 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs @@ -0,0 +1,101 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +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. +/// +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) + { + Type? attrType = null; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + attrType = asm.GetType("Jellyfin.Server.Migrations.JellyfinMigrationAttribute"); + if (attrType is not null) + { + break; + } + } + + 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;"; + + var count = 0; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] allTypes; + try + { + allTypes = asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + allTypes = ex.Types.Where(t => t is not null).ToArray()!; + } + + 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++; + } + } + } + } + + log($"[CodeMigrations] {count} migration(es) pre-marcadas."); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationDiscovery.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationDiscovery.cs new file mode 100644 index 0000000..9adc990 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationDiscovery.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Table and column discovery helpers for SQLite and PostgreSQL. +/// +internal static class MigrationDiscovery +{ + // ── SQLite helpers ──────────────────────────────────────────────────────── + + internal static async Task> GetSqliteTablesAsync(SqliteConnection sqlite) + { + const string sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name;"; + var result = new List(); + using var cmd = new SqliteCommand(sql, sqlite); + using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + result.Add(reader.GetString(0)); + } + + return result; + } + + internal static async Task> GetSqliteColumnNamesAsync(SqliteConnection sqlite, string table) + { + var result = new List(); + using var cmd = CreatePragmaTableInfoCommand(sqlite, table); + using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + result.Add(reader.GetString(reader.GetOrdinal("name"))); + } + + return result; + } + + // ── PostgreSQL helpers ──────────────────────────────────────────────────── + + internal static async Task> GetPgColumnsAsync( + NpgsqlConnection pg, string schema, string table) + { + const string sql = @" + SELECT column_name, data_type, character_maximum_length + FROM information_schema.columns + WHERE table_schema = @schema AND table_name = @table + ORDER BY ordinal_position;"; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var cmd = new NpgsqlCommand(sql, pg); + cmd.Parameters.AddWithValue("schema", schema); + cmd.Parameters.AddWithValue("table", table); + using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + var colName = reader.GetString(0); + var dataType = reader.GetString(1); + int? maxLen = await reader.IsDBNullAsync(2).ConfigureAwait(false) ? null : reader.GetInt32(2); + result[colName] = (dataType, maxLen); + } + + return result; + } + + internal static async Task> SortTablesByFkDependencyAsync( + NpgsqlConnection pg, string schema, List tables) + { + const string fkSql = @" + SELECT DISTINCT + tc.table_name AS dependent_table, + ccu.table_name AS referenced_table + FROM information_schema.table_constraints tc + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + JOIN information_schema.constraint_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name + AND ccu.table_schema = @schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = @schema;"; + + var edges = new List<(string From, string To)>(); + using var cmd = new NpgsqlCommand(fkSql, pg); + cmd.Parameters.AddWithValue("schema", schema); + using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + var from = reader.GetString(0); + var to = reader.GetString(1); + if (!string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) + { + edges.Add((from, to)); + } + } + + var tableSet = new HashSet(tables, StringComparer.OrdinalIgnoreCase); + var inDegree = new Dictionary(StringComparer.OrdinalIgnoreCase); + var adjacency = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var t in tableSet) + { + inDegree[t] = 0; + adjacency[t] = new List(); + } + + foreach (var (from, to) in edges) + { + if (!tableSet.Contains(from) || !tableSet.Contains(to)) + { + continue; + } + + inDegree[from]++; + adjacency[to].Add(from); + } + + var queue = new Queue(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key).OrderBy(t => t)); + var sorted = new List(tables.Count); + while (queue.Count > 0) + { + var t = queue.Dequeue(); + sorted.Add(t); + foreach (var dep in adjacency[t].OrderBy(x => x)) + { + if (--inDegree[dep] == 0) + { + queue.Enqueue(dep); + } + } + } + + foreach (var t in tables) + { + if (!sorted.Contains(t, StringComparer.OrdinalIgnoreCase)) + { + sorted.Add(t); + } + } + + return sorted; + } + + // ── Quoting helpers ─────────────────────────────────────────────────────── + + internal static string QuoteIdentifier(string input) + => $"\"{input.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + + // ── Command factory (CA2100: table from sqlite_master, not user input) ──── + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "table comes from sqlite_master system catalog, not user input. Identifier is quoted to prevent injection.")] + private static SqliteCommand CreatePragmaTableInfoCommand(SqliteConnection sqlite, string table) + { + var sql = string.Concat("PRAGMA table_info(\"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\");"); + return new SqliteCommand(sql, sqlite); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs new file mode 100644 index 0000000..2ad19dc --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs @@ -0,0 +1,223 @@ +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 Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Orchestrates the SQLite → PostgreSQL migration by calling focused helpers. +/// +internal static class MigrationEngine +{ + private static readonly HashSet SkipTables = new(StringComparer.OrdinalIgnoreCase) + { "__EFMigrationsHistory", "__EFMigrationsLock" }; + + internal static async Task RunAsync( + string sqlitePath, + string postgresConnectionString, + string schema, + int batchSize, + bool truncate, + IApplicationPaths? applicationPaths, + MigrationService svc, + CancellationToken ct) + { + if (!File.Exists(sqlitePath)) + { + throw new FileNotFoundException($"SQLite file not found: {sqlitePath}", sqlitePath); + } + + svc.Log($"Iniciando migración: {sqlitePath} → PostgreSQL (schema={schema}, batch={batchSize})"); + + using var sqlite = new SqliteConnection($"Data Source={sqlitePath};Mode=ReadOnly;Cache=Shared"); + using var pg = new NpgsqlConnection(postgresConnectionString); + + await sqlite.OpenAsync(ct).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); + + 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"); + + tablesToMigrate = 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))) + { + 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)); + + if (needNormalizedFix) + { + 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)>(); + long totalRows = 0; + + try + { + 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 sqliteCols = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, table).ConfigureAwait(false); + if (sqliteCols.Count == 0) + { + svc.Log($"[{table}] sin columnas en SQLite, omitido."); + continue; + } + + var pgCols = await MigrationDiscovery.GetPgColumnsAsync(pg, schema, table).ConfigureAwait(false); + if (pgCols.Count == 0) + { + svc.Log($"[{table}] no existe en PostgreSQL, omitido."); + continue; + } + + var commonCols = sqliteCols + .Where(sc => pgCols.ContainsKey(sc)) + .Select(sc => new TargetColumn(sc, pgCols[sc].PgType, pgCols[sc].MaxLength)) + .ToList(); + + if (commonCols.Count == 0) + { + svc.Log($"[{table}] ninguna columna coincide, omitido."); + continue; + } + + 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); + } + } + + if (needNormalizedFix) + { + 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("Reseteando secuencias PostgreSQL..."); + await MigrationSchemaPreparer.ResetSequencesAsync(pg, schema).ConfigureAwait(false); + svc.Log("Secuencias reseteadas."); + } + finally + { + await MigrationFkManager + .SetFkTriggersAsync(pg, schema, tablesToMigrate, enable: true, svc.Log, MigrationNullLogger.Instance) + .ConfigureAwait(false); + } + + svc.SetPercentComplete(100); + + if (errors.Count > 0) + { + svc.Log($"[WARN] {errors.Count} tabla(s) fallaron: " + string.Join(", ", errors.Select(e => e.Table))); + } + + svc.Log($"Migración completada. Total de filas copiadas: {totalRows}."); + PostgresLog.Warn($"[Migration] Total filas migradas: {totalRows:N0}"); + } + + // ── 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. + + [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) + { + var sql = string.Concat( + "ALTER TABLE ", + MigrationDiscovery.QuoteIdentifier(schema), + ".\"Users\" ALTER COLUMN \"NormalizedUsername\" DROP NOT NULL;"); + return new NpgsqlCommand(sql, pg); + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL built from server catalog data via QuoteIdentifier, not user input.")] + private static NpgsqlCommand CreateAlterNormalizedUsernameSetNotNullCommand(NpgsqlConnection pg, string schema) + { + var sql = string.Concat( + "ALTER TABLE ", + MigrationDiscovery.QuoteIdentifier(schema), + ".\"Users\" ALTER COLUMN \"NormalizedUsername\" SET NOT NULL;"); + return new NpgsqlCommand(sql, pg); + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL built from server catalog data via QuoteIdentifier, not user input.")] + private static NpgsqlCommand CreateFillNormalizedUsernameCommand(NpgsqlConnection pg, string schema) + { + var sql = string.Concat( + "UPDATE ", + MigrationDiscovery.QuoteIdentifier(schema), + ".\"Users\" SET \"NormalizedUsername\" = UPPER(\"Username\") WHERE \"NormalizedUsername\" IS NULL;"); + return new NpgsqlCommand(sql, pg); + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL built from QuoteIdentifier on schema/table from server catalog, not user input.")] + private static NpgsqlCommand CreateTruncateCommand(NpgsqlConnection pg, string schema, string table) + { + var sql = string.Concat( + "TRUNCATE TABLE ", + MigrationDiscovery.QuoteIdentifier(schema), + ".", + MigrationDiscovery.QuoteIdentifier(table), + " RESTART IDENTITY CASCADE;"); + return new NpgsqlCommand(sql, pg); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationFkManager.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationFkManager.cs new file mode 100644 index 0000000..67c785a --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationFkManager.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Manages FK trigger suspension and re-enabling during the migration. +/// Suspending FK triggers prevents orphaned-row errors from SQLite data. +/// +internal static class MigrationFkManager +{ + /// + /// Enables or disables all triggers (including FK constraint triggers) on the given tables. + /// Falls back gracefully per table if permission is denied. + /// + /// Open PostgreSQL connection. + /// Target schema name. + /// Tables on which triggers will be toggled. + /// to enable triggers; to disable. + /// Callback for progress messages. + /// Logger for per-table warnings. + /// A representing the asynchronous operation. + internal static async Task SetFkTriggersAsync( + NpgsqlConnection pg, + string schema, + IEnumerable tables, + bool enable, + Action log, + ILogger logger) + { + var action = enable ? "ENABLE" : "DISABLE"; + var tableList = tables.ToList(); + log($"[Triggers] {action} TRIGGER ALL en {tableList.Count} tabla(s)..."); + + var failed = 0; + foreach (var table in tableList) + { + try + { + using var cmd = CreateTriggerCommand(pg, schema, table, enable); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + catch (PostgresException ex) + { + failed++; + logger.LogWarning( + "No se pudo {Action} triggers en {Table}: {Error}", action, table, ex.MessageText); + } + } + + if (failed == 0) + { + log($"[Triggers] {action} TRIGGER ALL completado."); + } + else + { + log($"[Triggers] {action} TRIGGER ALL: {failed} tabla(s) sin permiso."); + } + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL uses hardcoded ENABLE/DISABLE keywords + QuoteIdentifier on schema/table from server catalog, not user input.")] + private static NpgsqlCommand CreateTriggerCommand(NpgsqlConnection pg, string schema, string table, bool enable) + { + var sql = enable + ? string.Concat("ALTER TABLE ", MigrationDiscovery.QuoteIdentifier(schema), ".", MigrationDiscovery.QuoteIdentifier(table), " ENABLE TRIGGER ALL;") + : string.Concat("ALTER TABLE ", MigrationDiscovery.QuoteIdentifier(schema), ".", MigrationDiscovery.QuoteIdentifier(table), " DISABLE TRIGGER ALL;"); + return new NpgsqlCommand(sql, pg); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationNullLogger.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationNullLogger.cs new file mode 100644 index 0000000..42d9c0b --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationNullLogger.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// Minimal no-op logger for contexts where no logger is injected. +internal sealed class MigrationNullLogger : ILogger +{ + internal static readonly MigrationNullLogger Instance = new(); + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => false; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs new file mode 100644 index 0000000..c7aae6c --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Handles schema creation, timestamp-column normalization, and sequence reset +/// for the SQLite → PostgreSQL migration. +/// +internal static class MigrationSchemaPreparer +{ + // ── Schema auto-creation ────────────────────────────────────────────────── + + /// + /// Applies the embedded schema SQL if the target schema does not exist yet. + /// + /// Open PostgreSQL connection. + /// Target schema name. + /// Callback for progress messages. + /// A representing the asynchronous operation. + internal static async Task ApplySchemaIfNeededAsync(NpgsqlConnection pg, string schema, Action log) + { + 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)) + { + schemaSql = await reader.ReadToEndAsync().ConfigureAwait(false); + } + + // 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(); + + foreach (var statement in statements) + { + using var cmd = CreateSchemaStatementCommand(pg, statement); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + log($"[Schema] {statements.Count} sentencias aplicadas."); + } + + // ── Timestamp column normalization ──────────────────────────────────────── + + /// + /// Converts all timestamp without time zone columns to timestamptz + /// so Npgsql can insert UTC values without conversion errors. + /// + /// Open PostgreSQL connection. + /// Target schema name. + /// Callback for progress messages. + /// A representing the asynchronous operation. + internal static async Task EnsureTimestampColumnsAreTimestamptzAsync(NpgsqlConnection pg, string schema, Action log) + { + const string findSql = """ + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = @s + AND data_type = 'timestamp without time zone' + ORDER BY table_name, ordinal_position; + """; + + var toConvert = new List<(string Table, string Column)>(); + using (var findCmd = new NpgsqlCommand(findSql, pg)) + { + findCmd.Parameters.AddWithValue("s", schema); + using var reader = await findCmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + toConvert.Add((reader.GetString(0), reader.GetString(1))); + } + } + + if (toConvert.Count == 0) + { + return; + } + + log($"[Schema] Convirtiendo {toConvert.Count} columna(s) timestamp → timestamptz..."); + foreach (var (table, column) in toConvert) + { + using var cmd = CreateAlterTimestamptzCommand(pg, schema, table, column); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + log("[Schema] Conversión de timestamps completada."); + } + + // ── Sequence reset ──────────────────────────────────────────────────────── + + /// + /// Resets all sequences to MAX(column) + 1 after data import + /// so that subsequent INSERTs do not conflict with migrated IDs. + /// + /// Open PostgreSQL connection. + /// Target schema name. + /// A representing the asynchronous operation. + internal static async Task ResetSequencesAsync(NpgsqlConnection pg, string schema) + { + using var cmd = CreateResetSequencesCommand(pg, schema); + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + // ── 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.")] + private static NpgsqlCommand CreateSchemaStatementCommand(NpgsqlConnection pg, string statement) + => new NpgsqlCommand(statement, pg); + + // All identifiers come from information_schema system catalog. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "schema/table/column come from information_schema system catalog, not user input. QuoteIdentifier prevents injection.")] + private static NpgsqlCommand CreateAlterTimestamptzCommand( + NpgsqlConnection pg, + string schema, + string table, + string column) + { + var q = MigrationDiscovery.QuoteIdentifier; + var sql = string.Concat( + "ALTER TABLE ", + q(schema), + ".", + q(table), + " ALTER COLUMN ", + q(column), + " TYPE timestamp with time zone", + " USING CASE WHEN ", + q(column), + " IS NULL THEN NULL ELSE ", + q(column), + " AT TIME ZONE 'UTC' END;"); + return new NpgsqlCommand(sql, pg); + } + + // Schema is a trusted internal value (not raw HTTP input); escaped for SQL literal safety. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "schema is an internal configuration value escaped with Replace, not raw user HTTP input.")] + private static NpgsqlCommand CreateResetSequencesCommand(NpgsqlConnection pg, string schema) + { + var safeSchema = schema.Replace("'", "''", StringComparison.Ordinal); + var sql = string.Concat( + "DO $$ DECLARE r RECORD; BEGIN FOR r IN ", + "SELECT nsp.nspname AS schema_name, seq.relname AS seq_name, tab.relname AS table_name, col.attname AS col_name ", + "FROM pg_class seq JOIN pg_namespace nsp ON nsp.oid = seq.relnamespace ", + "JOIN pg_depend dep ON dep.objid = seq.oid JOIN pg_class tab ON tab.oid = dep.refobjid ", + "JOIN pg_attribute col ON col.attrelid = tab.oid AND col.attnum = dep.refobjsubid ", + "WHERE seq.relkind = 'S' AND nsp.nspname = '", + safeSchema, + "' ", + "LOOP EXECUTE format(", + "'SELECT setval(%L::regclass, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',", + "format('%I.%I', r.schema_name, r.seq_name),", + " r.col_name,", + " r.schema_name,", + " r.table_name);", + " END LOOP; END $$;"); + return new NpgsqlCommand(sql, pg); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs index 51580fa..214e949 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs @@ -1,54 +1,31 @@ -using System; +using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; -using System.Reflection; -using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Jellyfin.Database.Providers.Postgres.Services.Models; using MediaBrowser.Common.Configuration; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -using Npgsql; -using NpgsqlTypes; namespace Jellyfin.Database.Providers.Postgres.Services; -/// Immutable snapshot of migration progress returned to the API layer. -public sealed record MigrationProgress( - bool IsRunning, - bool IsCompleted, - bool HasError, - string? ErrorMessage, - int PercentComplete, - string CurrentTable, - long MigratedRows, - long TotalRows, - IReadOnlyList LogLines); - /// -/// Runs the SQLite → PostgreSQL data migration in-process. -/// Mirrors the logic of the standalone migrator exe but: -/// - Reports progress for the plugin UI. -/// - Discovers code migrations from the running AppDomain instead of via --jellyfin-dll. -/// Thread-safe: only one migration can run at a time (enforced by ). +/// Runs the SQLite to PostgreSQL data migration in-process. +/// Thread-safe: only one migration can run at a time. /// public sealed class MigrationService : IDisposable { private const int DefaultBatch = 1000; private const int MaxLogLines = 500; - private static readonly HashSet SkipTables = new(StringComparer.OrdinalIgnoreCase) - { "__EFMigrationsHistory", "__EFMigrationsLock" }; + 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); + private readonly object _logLock = new(); - /// - public void Dispose() => _semaphore.Dispose(); - - // Live state — written by migration task, read by status endpoint private volatile bool _isRunning; private volatile bool _isCompleted; private volatile bool _hasError; @@ -57,18 +34,18 @@ public sealed class MigrationService : IDisposable private string _currentTable = string.Empty; private long _migratedRows; private long _totalRows; - private readonly List _logBuffer = new(MaxLogLines); - private readonly object _logLock = new(); /// - /// Initializes a new instance of . + /// Initializes a new instance of the class. /// - public MigrationService(ILogger logger) - { - _logger = logger; - } + /// Logger instance. + public MigrationService(ILogger logger) => _logger = logger; + + /// + public void Dispose() => _semaphore.Dispose(); /// Gets a snapshot of the current migration progress. + /// Current snapshot. public MigrationProgress GetProgress() { lock (_logLock) @@ -87,11 +64,15 @@ public MigrationProgress GetProgress() } /// - /// Starts the migration in a background task. Returns immediately; poll - /// for status. + /// Starts the migration in a background task. Returns false if one is already running. /// - /// if the migration was started; - /// if one is already running. + /// Absolute path to the source SQLite database file. + /// PostgreSQL connection string for the target database. + /// Target PostgreSQL schema name. + /// Number of rows per insert batch. + /// When , truncates each table before copying. + /// Jellyfin application paths, used to locate the default SQLite file. + /// if migration started; if one is already running. public bool StartMigration( string sqlitePath, string postgresConnectionString, @@ -102,7 +83,7 @@ public bool StartMigration( { if (!_semaphore.Wait(0)) { - return false; // already running + return false; } _isRunning = true; @@ -114,16 +95,32 @@ public bool StartMigration( _migratedRows = 0; _totalRows = 0; - lock (_logLock) { _logBuffer.Clear(); } + lock (_logLock) + { + _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}"); _ = Task.Run(async () => { try { - await RunMigrationAsync( - sqlitePath, postgresConnectionString, schema, batchSize, truncate, - applicationPaths, CancellationToken.None).ConfigureAwait(false); + await MigrationEngine.RunAsync( + sqlitePath, + postgresConnectionString, + schema, + batchSize, + truncate, + applicationPaths, + 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."); } catch (Exception ex) { @@ -131,6 +128,7 @@ await RunMigrationAsync( _errorMessage = ex.Message; Log($"[FATAL] {ex.Message}"); _logger.LogError(ex, "Migration failed with unhandled exception"); + PostgresLog.Error("[Migration] FALLIDA", ex); } finally { @@ -142,749 +140,24 @@ await RunMigrationAsync( return true; } - // ───────────────────────────────────────────────────────────────────────── - // Core migration logic - // ───────────────────────────────────────────────────────────────────────── - - private async Task RunMigrationAsync( - string sqlitePath, - string postgresConnectionString, - string schema, - int batchSize, - bool truncate, - IApplicationPaths? applicationPaths, - CancellationToken ct) - { - // Do NOT enable Npgsql.EnableLegacyTimestampBehavior. - // With legacy mode ON, Npgsql returns DateTimeKind.Local on reads (applying the - // PostgreSQL session timezone) and accepts Unspecified on writes. Without it, - // Npgsql v6+ requires DateTimeKind.Utc for timestamptz — which is exactly what we - // produce below by normalizing every parsed timestamp to DateTimeKind.Utc. - - if (!File.Exists(sqlitePath)) - { - throw new FileNotFoundException($"SQLite file not found: {sqlitePath}", sqlitePath); - } - - Log($"Iniciando migración: {sqlitePath} → PostgreSQL (schema={schema}, batch={batchSize})"); - - await using var sqlite = new SqliteConnection($"Data Source={sqlitePath};Mode=ReadOnly;Cache=Shared"); - await sqlite.OpenAsync(ct).ConfigureAwait(false); - - await using var pg = new NpgsqlConnection(postgresConnectionString); - await pg.OpenAsync(ct).ConfigureAwait(false); - - // Auto-create schema if needed - await ApplySchemaIfNeededAsync(pg, schema).ConfigureAwait(false); - - // Enforce timestamptz for all timestamp columns so UTC DateTime values can be inserted - // without Npgsql conversion errors. - await EnsureTimestampColumnsAreTimestamptzAsync(pg, schema).ConfigureAwait(false); - - // Pre-mark code migrations from running AppDomain (no external DLL needed) - await PreMarkCodeMigrationsAsync(pg).ConfigureAwait(false); - - var tables = await GetSqliteTablesAsync(sqlite).ConfigureAwait(false); - var tablesToMigrate = tables - .Where(t => !SkipTables.Contains(t)) - .ToList(); - - Log($"Tablas SQLite: {tables.Count} total, {tablesToMigrate.Count} a migrar"); - _totalRows = tablesToMigrate.Count; // use as step count before we know row counts - - tablesToMigrate = await SortTablesByFkDependencyAsync(pg, schema, tablesToMigrate).ConfigureAwait(false); - - // Disable FK triggers for the entire migration. - // SQLite never enforces FK constraints, so orphaned rows are common (e.g. AncestorIds - // referencing deleted BaseItems). Without this, every orphaned row causes a batch - // failure + row-by-row retry → thousands of [SKIP] messages and very slow migration. - // DISABLE TRIGGER ALL only requires table ownership (the Jellyfin PG user owns these tables). - await SetFkTriggersAsync(pg, schema, tablesToMigrate, enable: false).ConfigureAwait(false); - - // Users table: NormalizedUsername fixup - var needNormalizedUsernameFixup = false; - if (tablesToMigrate.Any(t => string.Equals(t, "Users", StringComparison.OrdinalIgnoreCase))) - { - var pgCols = await GetPgColumnsAsync(pg, schema, "Users").ConfigureAwait(false); - var sqliteCols = await GetSqliteColumnNamesAsync(sqlite, "Users").ConfigureAwait(false); - needNormalizedUsernameFixup = pgCols.ContainsKey("NormalizedUsername") - && !sqliteCols.Any(c => string.Equals(c, "NormalizedUsername", StringComparison.OrdinalIgnoreCase)); - if (needNormalizedUsernameFixup) - { - Log("[Users] Permitiendo NULL en NormalizedUsername temporalmente..."); - await using var alterCmd = new NpgsqlCommand( - $"ALTER TABLE {QuoteIdentifier(schema)}.\"Users\" ALTER COLUMN \"NormalizedUsername\" DROP NOT NULL;", pg); - await alterCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - } - - var errors = new List<(string Table, string Message)>(); - long totalRows = 0; - - try - { - for (var i = 0; i < tablesToMigrate.Count; i++) - { - var table = tablesToMigrate[i]; - _currentTable = table; - _percentComplete = (int)Math.Round((double)i / tablesToMigrate.Count * 95); - - var sqliteColumns = await GetSqliteColumnNamesAsync(sqlite, table).ConfigureAwait(false); - if (sqliteColumns.Count == 0) - { - Log($"[{table}] sin columnas en SQLite, omitido."); - continue; - } - - var pgColumns = await GetPgColumnsAsync(pg, schema, table).ConfigureAwait(false); - if (pgColumns.Count == 0) - { - Log($"[{table}] no existe en PostgreSQL, omitido."); - continue; - } - - var commonColumns = sqliteColumns - .Where(sc => pgColumns.ContainsKey(sc)) - .Select(sc => new TargetColumn(sc, pgColumns[sc].PgType, pgColumns[sc].MaxLength)) - .ToList(); - - if (commonColumns.Count == 0) - { - Log($"[{table}] ninguna columna coincide, omitido."); - continue; - } - - try - { - if (truncate) - { - var pgTable = $"{QuoteIdentifier(schema)}.{QuoteIdentifier(table)}"; - Log($"[{table}] truncando..."); - await using var truncCmd = new NpgsqlCommand( - $"TRUNCATE TABLE {pgTable} RESTART IDENTITY CASCADE;", pg); - await truncCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - - Log($"[{table}] copiando {commonColumns.Count} columna(s)..."); - var rowCount = await CopyTableAsync(sqlite, pg, schema, table, commonColumns, batchSize).ConfigureAwait(false); - totalRows += rowCount; - _migratedRows = totalRows; - Log($"[{table}] OK — {rowCount} filas."); - } - catch (Exception ex) - { - Log($"[{table}] ERROR: {ex.Message}"); - errors.Add((table, ex.Message)); - } - } - - if (needNormalizedUsernameFixup) - { - Log("[Users] Calculando NormalizedUsername = UPPER(Username)..."); - await using var fixCmd = new NpgsqlCommand( - $"UPDATE {QuoteIdentifier(schema)}.\"Users\" SET \"NormalizedUsername\" = UPPER(\"Username\") WHERE \"NormalizedUsername\" IS NULL;", pg); - var updated = await fixCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - Log($"[Users] {updated} filas actualizadas."); - await using var restoreCmd = new NpgsqlCommand( - $"ALTER TABLE {QuoteIdentifier(schema)}.\"Users\" ALTER COLUMN \"NormalizedUsername\" SET NOT NULL;", pg); - await restoreCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - Log("[Users] Restricción NOT NULL restaurada."); - } - - Log("Reseteando secuencias PostgreSQL..."); - await ResetSequencesAsync(pg, schema).ConfigureAwait(false); - Log("Secuencias reseteadas."); - } - finally - { - // Always re-enable FK triggers, even if migration partially fails. - await SetFkTriggersAsync(pg, schema, tablesToMigrate, enable: true).ConfigureAwait(false); - } - - _percentComplete = 100; - - if (errors.Count > 0) - { - var msg = $"{errors.Count} tabla(s) fallaron: " + string.Join(", ", errors.Select(e => e.Table)); - Log($"[WARN] {msg}"); - // Don't throw — partial migration is still useful. HasError is NOT set. - } - - Log($"Migración completada. Total de filas copiadas: {totalRows}."); - } - - // ───────────────────────────────────────────────────────────────────────── - // Pre-mark code migrations (uses running AppDomain — no DLL needed) - // ───────────────────────────────────────────────────────────────────────── + // Internal state setters used by MigrationEngine + internal void SetPercentComplete(int value) => _percentComplete = value; - private async Task PreMarkCodeMigrationsAsync(NpgsqlConnection pg) - { - Type? attrType = null; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - attrType = asm.GetType("Jellyfin.Server.Migrations.JellyfinMigrationAttribute"); - if (attrType is not null) break; - } + internal void SetCurrentTable(string value) => _currentTable = value; - if (attrType is null) - { - Log("[CodeMigrations] JellyfinMigrationAttribute no encontrado en el AppDomain (¿versión vieja de Jellyfin?)."); - 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; - } - - var count = 0; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - Type[] allTypes; - try { allTypes = asm.GetTypes(); } - catch (ReflectionTypeLoadException ex) { allTypes = ex.Types.Where(t => t is not null).ToArray()!; } - - 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 name = (string)nameProp.GetValue(attr)!; - - foreach (var fmt in new[] { "yyyyMMddHHmmss", "yyyyMMddHHmmsss" }) - { - var migrationId = order.ToString(fmt, CultureInfo.InvariantCulture) + "_" + name; - await using var cmd = new NpgsqlCommand( - "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @ver) ON CONFLICT DO NOTHING;", - pg); - cmd.Parameters.AddWithValue("id", migrationId); - cmd.Parameters.AddWithValue("ver", "0.0.0"); - var inserted = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - if (inserted > 0) count++; - } - } - } - - Log($"[CodeMigrations] {count} migration(es) pre-marcadas."); - } + internal void AddMigratedRows(long count) => _migratedRows += count; - // ───────────────────────────────────────────────────────────────────────── - // Schema auto-creation - // ───────────────────────────────────────────────────────────────────────── - - private async Task ApplySchemaIfNeededAsync(NpgsqlConnection pg, string schema) + internal void Log(string message) { - await using var checkCmd = new NpgsqlCommand( - "SELECT 1 FROM information_schema.tables WHERE table_schema = @s AND table_name = 'BaseItems' LIMIT 1;", pg); - checkCmd.Parameters.AddWithValue("s", schema); - var 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..."); - var assembly = GetType().Assembly; - // Shared resource name — same SQL used by the migrator exe - const string resourceName = "Jellyfin.Database.Providers.Postgres.Resources.schema.sql"; - await using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Embedded resource not found: {resourceName}"); - using var reader = new StreamReader(stream, Encoding.UTF8); - var schemaSql = await reader.ReadToEndAsync().ConfigureAwait(false); - - var statements = schemaSql - .Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim().TrimEnd(';')) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToList(); - - foreach (var statement in statements) - { - await using var cmd = new NpgsqlCommand(statement, pg); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - Log($"[Schema] {statements.Count} sentencias aplicadas."); - } - - private async Task EnsureTimestampColumnsAreTimestamptzAsync(NpgsqlConnection pg, string schema) - { - const string findSql = """ - SELECT table_name, column_name - FROM information_schema.columns - WHERE table_schema = @s - AND data_type = 'timestamp without time zone' - ORDER BY table_name, ordinal_position; - """; - - var toConvert = new List<(string Table, string Column)>(); - await using (var findCmd = new NpgsqlCommand(findSql, pg)) - { - findCmd.Parameters.AddWithValue("s", schema); - await using var reader = await findCmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - toConvert.Add((reader.GetString(0), reader.GetString(1))); - } - } - - if (toConvert.Count == 0) - { - return; - } - - Log($"[Schema] Convirtiendo {toConvert.Count} columna(s) timestamp -> timestamptz..."); - foreach (var (table, column) in toConvert) - { - var sql = $""" - ALTER TABLE {QuoteIdentifier(schema)}.{QuoteIdentifier(table)} - ALTER COLUMN {QuoteIdentifier(column)} TYPE timestamp with time zone - USING CASE - WHEN {QuoteIdentifier(column)} IS NULL THEN NULL - ELSE {QuoteIdentifier(column)} AT TIME ZONE 'UTC' - END; - """; - await using var cmd = new NpgsqlCommand(sql, pg); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - Log("[Schema] Conversión de timestamps completada."); - } - - // ───────────────────────────────────────────────────────────────────────── - // Table / column discovery - // ───────────────────────────────────────────────────────────────────────── - - private static async Task> GetSqliteTablesAsync(SqliteConnection sqlite) - { - const string sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name;"; - var result = new List(); - await using var cmd = new SqliteCommand(sql, sqlite); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - result.Add(reader.GetString(0)); - } - return result; - } - - private static async Task> GetSqliteColumnNamesAsync(SqliteConnection sqlite, string table) - { - var result = new List(); - await using var cmd = new SqliteCommand($"PRAGMA table_info({QuoteSqliteIdentifier(table)});", sqlite); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - result.Add(reader.GetString(reader.GetOrdinal("name"))); - } - return result; - } - - private static async Task> GetPgColumnsAsync( - NpgsqlConnection pg, string schema, string table) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - await using var cmd = new NpgsqlCommand(@" - SELECT column_name, data_type, character_maximum_length - FROM information_schema.columns - WHERE table_schema = @schema AND table_name = @table - ORDER BY ordinal_position;", pg); - cmd.Parameters.AddWithValue("schema", schema); - cmd.Parameters.AddWithValue("table", table); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - var colName = reader.GetString(0); - var dataType = reader.GetString(1); - int? maxLen = await reader.IsDBNullAsync(2).ConfigureAwait(false) ? null : reader.GetInt32(2); - result[colName] = (dataType, maxLen); - } - return result; - } - - // ───────────────────────────────────────────────────────────────────────── - // FK topological sort (Kahn's algorithm) - // ───────────────────────────────────────────────────────────────────────── - - private async Task> SortTablesByFkDependencyAsync( - NpgsqlConnection pg, string schema, List tables) - { - var edges = new List<(string From, string To)>(); - await using var cmd = new NpgsqlCommand(@" - SELECT DISTINCT - tc.table_name AS dependent_table, - ccu.table_name AS referenced_table - FROM information_schema.table_constraints tc - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - AND tc.table_schema = rc.constraint_schema - JOIN information_schema.constraint_column_usage ccu - ON rc.unique_constraint_name = ccu.constraint_name - AND ccu.table_schema = @schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = @schema;", pg); - cmd.Parameters.AddWithValue("schema", schema); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - var from = reader.GetString(0); - var to = reader.GetString(1); - if (!string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) - { - edges.Add((from, to)); - } - } - - var tableSet = new HashSet(tables, StringComparer.OrdinalIgnoreCase); - var inDegree = new Dictionary(StringComparer.OrdinalIgnoreCase); - var adjacency = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var t in tableSet) { inDegree[t] = 0; adjacency[t] = new List(); } - - foreach (var (from, to) in edges) - { - if (!tableSet.Contains(from) || !tableSet.Contains(to)) continue; - inDegree[from]++; - adjacency[to].Add(from); - } - - var queue = new Queue(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key).OrderBy(t => t)); - var sorted = new List(tables.Count); - while (queue.Count > 0) - { - var t = queue.Dequeue(); - sorted.Add(t); - foreach (var dep in adjacency[t].OrderBy(x => x)) - { - if (--inDegree[dep] == 0) queue.Enqueue(dep); - } - } - - foreach (var t in tables) - { - if (!sorted.Contains(t, StringComparer.OrdinalIgnoreCase)) sorted.Add(t); - } - - Log($"Orden de migración: {string.Join(" → ", sorted)}"); - return sorted; - } - - // ───────────────────────────────────────────────────────────────────────── - // Data copy - // ───────────────────────────────────────────────────────────────────────── - - private async Task CopyTableAsync( - SqliteConnection sqlite, NpgsqlConnection pg, string schema, - string table, IReadOnlyList columns, int batchSize) - { - var colList = string.Join(", ", columns.Select(c => QuoteSqliteIdentifier(c.Name))); - var selectSql = $"SELECT {colList} FROM {QuoteSqliteIdentifier(table)};"; - await using var selectCmd = new SqliteCommand(selectSql, sqlite); - await using var reader = await selectCmd.ExecuteReaderAsync().ConfigureAwait(false); - - var pgColumnList = string.Join(", ", columns.Select(c => QuoteIdentifier(c.Name))); - var pgTable = $"{QuoteIdentifier(schema)}.{QuoteIdentifier(table)}"; - - var effectiveBatchSize = Math.Max(1, Math.Min(batchSize, 65_535 / columns.Count)); - - 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; - } - } - - long total = 0; - var rows = new List(effectiveBatchSize); - - 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(); - } - - rows.Add(values); - if (rows.Count >= effectiveBatchSize) - { - total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, rows).ConfigureAwait(false); - _migratedRows = total; - rows.Clear(); - } - } - - if (rows.Count > 0) - { - total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, rows).ConfigureAwait(false); - _migratedRows = total; - } - - return total; - } - - private async Task InsertBatchAsync( - NpgsqlConnection pg, string pgTable, string pgColumnList, - IReadOnlyList columns, IReadOnlyList rows) - { - await using var tx = await pg.BeginTransactionAsync().ConfigureAwait(false); - var valuesSql = new List(rows.Count); - await using var cmd = new NpgsqlCommand { Connection = pg, Transaction = tx }; - - var paramIndex = 0; - for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) - { - var placeholders = new string[columns.Count]; - for (var colIndex = 0; colIndex < columns.Count; colIndex++) - { - var paramName = $"p{paramIndex++}"; - placeholders[colIndex] = $"@{paramName}"; - var rawValue = rows[rowIndex][colIndex]; - var column = columns[colIndex]; - var param = new NpgsqlParameter { ParameterName = paramName }; - - if (rawValue is null or DBNull) - { - param.Value = DBNull.Value; - } - else if (rawValue is byte[] binaryValue) - { - param.NpgsqlDbType = NpgsqlDbType.Bytea; - param.Value = binaryValue; - } - else - { - var pgType = column.PgType; - var strVal = Convert.ToString(rawValue, CultureInfo.InvariantCulture) ?? string.Empty; - - if (pgType is "boolean") - { - if (long.TryParse(strVal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var boolLong)) - { param.NpgsqlDbType = NpgsqlDbType.Boolean; param.Value = boolLong != 0; } - else if (bool.TryParse(strVal, out var boolVal)) - { param.NpgsqlDbType = NpgsqlDbType.Boolean; param.Value = boolVal; } - else - { param.NpgsqlDbType = NpgsqlDbType.Boolean; param.Value = DBNull.Value; - Log($" [WARN] {pgTable}.{column.Name}: boolean esperado, valor '{strVal}' → NULL"); } - } - else if (pgType is "bigint" or "integer" or "smallint") - { - if (long.TryParse(strVal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longVal)) - { param.NpgsqlDbType = NpgsqlDbType.Bigint; param.Value = longVal; } - else if (string.Equals(column.Name, "TotalDuration", StringComparison.OrdinalIgnoreCase) - && pgTable.EndsWith("\"KeyframeData\"", StringComparison.Ordinal)) - { param.NpgsqlDbType = NpgsqlDbType.Bigint; param.Value = 0L; - Log($" [WARN] {pgTable}.{column.Name}: bigint inválido '{strVal}' -> 0"); } - else - { param.NpgsqlDbType = NpgsqlDbType.Bigint; param.Value = DBNull.Value; - Log($" [WARN] {pgTable}.{column.Name}: {pgType} esperado, valor '{strVal}' → NULL"); } - } - else if (pgType is "double precision" or "real" or "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} esperado, valor '{strVal}' → NULL"); } - } - else if (pgType is "timestamp without time zone" or "timestamp with time zone") - { - // RoundtripKind preserves kind information when present (Z/+00:00) and - // accepts plain values without timezone. Do NOT combine with Assume/Adjust flags. - if (DateTime.TryParse(strVal, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dtVal)) - { - var isTimestampTz = pgType == "timestamp with time zone"; - if (isTimestampTz) - { - // Npgsql requires UTC for timestamptz parameters. - // Unspecified values from SQLite are treated as already-UTC values. - if (dtVal.Kind == DateTimeKind.Unspecified) - dtVal = DateTime.SpecifyKind(dtVal, DateTimeKind.Utc); - else if (dtVal.Kind == DateTimeKind.Local) - dtVal = dtVal.ToUniversalTime(); - - param.NpgsqlDbType = NpgsqlDbType.TimestampTz; - param.Value = dtVal; - } - else - { - // Keep clock time for "timestamp without time zone". - if (dtVal.Kind != DateTimeKind.Unspecified) - dtVal = DateTime.SpecifyKind(dtVal, DateTimeKind.Unspecified); - - param.NpgsqlDbType = NpgsqlDbType.Timestamp; - param.Value = dtVal; - } - } - else - { - param.NpgsqlDbType = pgType == "timestamp with time zone" - ? NpgsqlDbType.TimestampTz - : NpgsqlDbType.Timestamp; - param.Value = DBNull.Value; - Log($" [WARN] {pgTable}.{column.Name}: timestamp esperado, valor '{strVal}' → NULL"); - } - } - else if (pgType is "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 esperado, valor '{strVal}' no es JSON array → NULL"); } - } - else - { - var textVal = strVal; - if (column.MaxLength.HasValue && textVal.Length > column.MaxLength.Value) - textVal = textVal[..column.MaxLength.Value]; - param.NpgsqlDbType = NpgsqlDbType.Unknown; - param.Value = textVal; - } - } - - cmd.Parameters.Add(param); - } - valuesSql.Add($"({string.Join(", ", placeholders)})"); - } - - cmd.CommandText = $"INSERT INTO {pgTable} ({pgColumnList}) VALUES {string.Join(", ", valuesSql)} ON CONFLICT DO NOTHING;"; - try - { - var inserted = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - await tx.CommitAsync().ConfigureAwait(false); - // ON CONFLICT DO NOTHING returns the count of rows actually inserted. - // Any row whose PK already exists in PostgreSQL is silently skipped — log it - // so users can diagnose partial migrations or re-migrations. - var skipped = rows.Count - inserted; - if (skipped > 0) - Log($" [SKIP] {pgTable}: {skipped} fila(s) omitidas (PK ya existe), {inserted} insertadas."); - return inserted; - } - catch (PostgresException pgEx) - { - await tx.RollbackAsync().ConfigureAwait(false); - if (rows.Count == 1) - { - Log($" [SKIP] {pgTable}: fila saltada ({pgEx.SqlState}): {pgEx.MessageText}"); - return 0; - } - Log($" [WARN] {pgTable}: batch de {rows.Count} filas falló ({pgEx.SqlState}), reintentando fila por fila..."); - long total = 0; - foreach (var row in rows) - total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, new[] { row }).ConfigureAwait(false); - return total; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Sequence reset - // ───────────────────────────────────────────────────────────────────────── - - private static async Task ResetSequencesAsync(NpgsqlConnection pg, string schema) - { - var safeSchema = schema.Replace("'", "''", StringComparison.Ordinal); - var sql = $""" - DO $$ - DECLARE r RECORD; - BEGIN - FOR r IN - SELECT nsp.nspname AS schema_name, - seq.relname AS seq_name, - tab.relname AS table_name, - col.attname AS col_name - FROM pg_class seq - JOIN pg_namespace nsp ON nsp.oid = seq.relnamespace - JOIN pg_depend dep ON dep.objid = seq.oid - JOIN pg_class tab ON tab.oid = dep.refobjid - JOIN pg_attribute col ON col.attrelid = tab.oid - AND col.attnum = dep.refobjsubid - WHERE seq.relkind = 'S' - AND nsp.nspname = '{safeSchema}' - LOOP - EXECUTE format( - 'SELECT setval(%L::regclass, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)', - format('%I.%I', r.schema_name, r.seq_name), - r.col_name, - r.schema_name, - r.table_name); - END LOOP; - END $$; - """; - await using var cmd = new NpgsqlCommand(sql, pg); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - // ───────────────────────────────────────────────────────────────────────── - // FK trigger management - // ───────────────────────────────────────────────────────────────────────── - - /// - /// Enables or disables all triggers (including FK constraint triggers) on the given tables. - /// Requires the current PG user to own the tables (which is the case when Jellyfin - /// created the schema). Falls back gracefully per table if permission is denied. - /// - private async Task SetFkTriggersAsync( - NpgsqlConnection pg, string schema, IEnumerable tables, bool enable) - { - var action = enable ? "ENABLE" : "DISABLE"; - var tableList = tables.ToList(); - Log($"[Triggers] {action} TRIGGER ALL en {tableList.Count} tabla(s)..."); - var failed = 0; - foreach (var table in tableList) + _logger.LogInformation("{Message}", message); + lock (_logLock) { - try - { - await using var cmd = new NpgsqlCommand( - $"ALTER TABLE {QuoteIdentifier(schema)}.{QuoteIdentifier(table)} {action} TRIGGER ALL;", pg); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - catch (PostgresException ex) + if (_logBuffer.Count >= MaxLogLines) { - failed++; - _logger.LogWarning( - "No se pudo {Action} triggers en {Table}: {Error}", action, table, ex.MessageText); + _logBuffer.RemoveAt(0); } - } - if (failed == 0) - Log($"[Triggers] {action} TRIGGER ALL completado."); - else - Log($"[Triggers] {action} TRIGGER ALL: {failed} tabla(s) sin permiso (verificación de FK activa en esas tablas)."); - } - - private void Log(string message) - { - _logger.LogInformation("{Message}", message); - lock (_logLock) - { - if (_logBuffer.Count >= MaxLogLines) _logBuffer.RemoveAt(0); _logBuffer.Add($"[{DateTime.UtcNow:HH:mm:ss}] {message}"); } } - - private static string QuoteIdentifier(string input) - => $"\"{input.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; - - private static string QuoteSqliteIdentifier(string input) - => $"\"{input.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; - - private sealed record TargetColumn(string Name, string PgType, int? MaxLength = null); } diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs new file mode 100644 index 0000000..c29825d --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +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; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Copies rows from a SQLite table into the corresponding PostgreSQL table, +/// with type conversion and batch-insert retry logic. +/// +internal static class MigrationTableCopier +{ + /// + /// Copies all rows of from SQLite into PostgreSQL. + /// Returns the number of rows inserted. + /// + /// Open SQLite source connection. + /// Open PostgreSQL target connection. + /// Target PostgreSQL schema. + /// Table name to copy. + /// Columns to include in the copy. + /// Number of rows per insert batch. + /// Callback for progress messages. + /// Number of rows successfully inserted. + internal static async Task CopyTableAsync( + SqliteConnection sqlite, + NpgsqlConnection pg, + string schema, + string table, + IReadOnlyList columns, + int batchSize, + Action log) + { + var colList = string.Join(", ", columns.Select(c => MigrationDiscovery.QuoteIdentifier(c.Name))); + + // selectSql uses QuoteIdentifier on names from sqlite_master (system catalog) — not user input (CA2100) + var selectSql = $"SELECT {colList} FROM {MigrationDiscovery.QuoteIdentifier(table)};"; + var pgColumnList = string.Join(", ", columns.Select(c => MigrationDiscovery.QuoteIdentifier(c.Name))); + var pgTable = $"{MigrationDiscovery.QuoteIdentifier(schema)}.{MigrationDiscovery.QuoteIdentifier(table)}"; + + 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; + } + } + } + + long total = 0; + var rows = new List(effectiveBatchSize); + + using var selectCmd = CreateSqliteSelectCommand(sqlite, selectSql); + using var reader = await selectCmd.ExecuteReaderAsync().ConfigureAwait(false); + + 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(); + } + + rows.Add(values); + if (rows.Count >= effectiveBatchSize) + { + total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, rows, log) + .ConfigureAwait(false); + rows.Clear(); + } + } + + if (rows.Count > 0) + { + total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, rows, log) + .ConfigureAwait(false); + } + + return total; + } + + // ── Batch insert ────────────────────────────────────────────────────────── + + private static async Task InsertBatchAsync( + NpgsqlConnection pg, + string pgTable, + string pgColumnList, + IReadOnlyList columns, + IReadOnlyList rows, + Action log) + { + using var tx = await pg.BeginTransactionAsync().ConfigureAwait(false); + using var cmd = new NpgsqlCommand { Connection = pg, Transaction = tx }; + + var valuesSql = new List(rows.Count); + var paramIndex = 0; + + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var placeholders = new string[columns.Count]; + for (var colIndex = 0; colIndex < columns.Count; colIndex++) + { + var paramName = $"p{paramIndex++}"; + placeholders[colIndex] = $"@{paramName}"; + cmd.Parameters.Add(BuildParameter(paramName, rows[rowIndex][colIndex], columns[colIndex], pgTable, log)); + } + + valuesSql.Add($"({string.Join(", ", placeholders)})"); + } + + // pgTable + pgColumnList come from QuoteIdentifier on catalog data — not user input (CA2100) + SetInsertCommandText(cmd, pgTable, pgColumnList, valuesSql); + + try + { + var inserted = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + await tx.CommitAsync().ConfigureAwait(false); + var skipped = rows.Count - inserted; + if (skipped > 0) + { + log($" [SKIP] {pgTable}: {skipped} fila(s) omitidas (PK ya existe), {inserted} insertadas."); + } + + return inserted; + } + catch (PostgresException pgEx) + { + await tx.RollbackAsync().ConfigureAwait(false); + if (rows.Count == 1) + { + log($" [SKIP] {pgTable}: fila saltada ({pgEx.SqlState}): {pgEx.MessageText}"); + return 0; + } + + log($" [WARN] {pgTable}: batch de {rows.Count} filas falló ({pgEx.SqlState}), reintentando fila por fila..."); + long total = 0; + foreach (var row in rows) + { + total += await InsertBatchAsync(pg, pgTable, pgColumnList, columns, new[] { row }, log) + .ConfigureAwait(false); + } + + return total; + } + } + + // ── Type conversion ─────────────────────────────────────────────────────── + + private static NpgsqlParameter BuildParameter( + string name, object? rawValue, TargetColumn column, string pgTable, Action log) + { + var param = new NpgsqlParameter { ParameterName = name }; + + if (rawValue is null or DBNull) + { + param.Value = DBNull.Value; + return param; + } + + if (rawValue is byte[] binaryValue) + { + param.NpgsqlDbType = NpgsqlDbType.Bytea; + param.Value = binaryValue; + return param; + } + + var pgType = column.PgType; + var strVal = Convert.ToString(rawValue, CultureInfo.InvariantCulture) ?? string.Empty; + + switch (pgType) + { + case "boolean": + if (long.TryParse(strVal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var boolLong)) + { + param.NpgsqlDbType = NpgsqlDbType.Boolean; + param.Value = boolLong != 0; + } + else if (bool.TryParse(strVal, out var boolVal)) + { + param.NpgsqlDbType = NpgsqlDbType.Boolean; + param.Value = boolVal; + } + else + { + param.NpgsqlDbType = NpgsqlDbType.Boolean; + param.Value = DBNull.Value; + log($" [WARN] {pgTable}.{column.Name}: boolean inválido '{strVal}' → NULL"); + } + + break; + + 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"); + } + + 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"); + } + + break; + + case "timestamp without time zone": + case "timestamp with time zone": + SetTimestampParameter(param, strVal, pgType, pgTable, column.Name, log); + 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"); + } + + break; + + default: + var textVal = strVal; + if (column.MaxLength.HasValue && textVal.Length > column.MaxLength.Value) + { + textVal = textVal[..column.MaxLength.Value]; + } + + param.NpgsqlDbType = NpgsqlDbType.Unknown; + param.Value = textVal; + break; + } + + return param; + } + + private static void SetTimestampParameter( + NpgsqlParameter param, string strVal, string pgType, string pgTable, string colName, Action log) + { + var isTz = pgType == "timestamp with time zone"; + if (DateTime.TryParse(strVal, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dtVal)) + { + if (isTz) + { + if (dtVal.Kind == DateTimeKind.Unspecified) + { + dtVal = DateTime.SpecifyKind(dtVal, DateTimeKind.Utc); + } + else if (dtVal.Kind == DateTimeKind.Local) + { + dtVal = dtVal.ToUniversalTime(); + } + + param.NpgsqlDbType = NpgsqlDbType.TimestampTz; + param.Value = dtVal; + } + else + { + if (dtVal.Kind != DateTimeKind.Unspecified) + { + dtVal = DateTime.SpecifyKind(dtVal, DateTimeKind.Unspecified); + } + + param.NpgsqlDbType = NpgsqlDbType.Timestamp; + param.Value = dtVal; + } + } + else + { + param.NpgsqlDbType = isTz ? NpgsqlDbType.TimestampTz : NpgsqlDbType.Timestamp; + param.Value = DBNull.Value; + log($" [WARN] {pgTable}.{colName}: timestamp inválido '{strVal}' → NULL"); + } + } + + // ── Command factory methods (CA2100) ────────────────────────────────────── + + // selectSql is built from QuoteIdentifier on catalog-derived names, not user input. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "selectSql built from QuoteIdentifier on sqlite_master catalog data, not user input.")] + private static SqliteCommand CreateSqliteSelectCommand(SqliteConnection sqlite, string selectSql) + => new SqliteCommand(selectSql, 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.")] + private static void SetInsertCommandText( + NpgsqlCommand cmd, + string pgTable, + string pgColumnList, + System.Collections.Generic.List valuesSql) + { + cmd.CommandText = string.Concat( + "INSERT INTO ", + pgTable, + " (", + pgColumnList, + ") VALUES ", + string.Join(", ", valuesSql), + " ON CONFLICT DO NOTHING;"); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/BackupFileInfo.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/BackupFileInfo.cs new file mode 100644 index 0000000..d695af2 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/BackupFileInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Metadata about a backup file found in the backup directory. +/// File name without directory path. +/// Absolute path to the backup file. +/// File size in bytes. +/// Human-readable file size (e.g. "45.3 MB"). +/// File last-write timestamp in UTC. +public sealed record BackupFileInfo( + string Name, + string Path, + long SizeBytes, + string SizeFormatted, + DateTime CreatedAtUtc); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/ColumnInfo.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/ColumnInfo.cs new file mode 100644 index 0000000..b7e041a --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/ColumnInfo.cs @@ -0,0 +1,4 @@ +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// Describes a PostgreSQL column (name + data type). +internal sealed record ColumnInfo(string Name, string PgDataType); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/ExportToSqliteProgress.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/ExportToSqliteProgress.cs new file mode 100644 index 0000000..daaab50 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/ExportToSqliteProgress.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Snapshot of an in-progress PostgreSQL → SQLite export. +/// Whether the export is currently running. +/// Whether the export has completed successfully. +/// Whether the export encountered a fatal error. +/// The error message, if any. +/// Estimated completion percentage (0–100). +/// The table currently being exported. +/// Total rows written to SQLite so far. +/// Estimated total rows to export. +/// Destination SQLite file path. +/// Recent log lines from the export process. +public sealed record ExportToSqliteProgress( + bool IsRunning, + bool IsCompleted, + bool HasError, + string? ErrorMessage, + int PercentComplete, + string CurrentTable, + long ExportedRows, + long TotalRows, + string? TargetSqlitePath, + IReadOnlyList LogLines); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/GinIndexInfo.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/GinIndexInfo.cs new file mode 100644 index 0000000..24316f9 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/GinIndexInfo.cs @@ -0,0 +1,8 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Status of a plugin-managed GIN trigram index. +public sealed record GinIndexInfo( + string IndexName, + string TableName, + string IndexSize, + bool IsValid); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/HealthCheckResult.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthCheckResult.cs new file mode 100644 index 0000000..d7a19f7 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthCheckResult.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Aggregated result of all health checks. +/// Worst severity across all findings. +/// All individual findings. +/// Timestamp when the check ran. +/// How long the full check took in milliseconds. +public sealed record HealthCheckResult( + HealthSeverity OverallSeverity, + IReadOnlyList Findings, + DateTime CheckedAtUtc, + long DurationMs); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/HealthFinding.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthFinding.cs new file mode 100644 index 0000000..bbc1c8d --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthFinding.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Single health check finding. +/// Short identifier for the check (e.g. "InvalidIndexes"). +/// Severity of this finding. +/// Human-readable description. +/// Optional structured detail (table name, index name, etc.). +/// Whether this finding can be auto-repaired by the plugin. +public sealed record HealthFinding( + string Check, + HealthSeverity Severity, + string Message, + string? Detail = null, + bool Repairable = false); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairItem.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairItem.cs new file mode 100644 index 0000000..e7ffb9b --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairItem.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Result of a single auto-repair action. +/// Short identifier for the repair action (e.g. "Reindex", "Vacuum", "FixSequence"). +/// Identifier of the object that was repaired (index, table, sequence). +/// Whether the action completed successfully. +/// Human-readable result message (or error description). +public sealed record HealthRepairItem( + string Action, + string Target, + bool Success, + string? Message = null); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairResult.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairResult.cs new file mode 100644 index 0000000..6087d35 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthRepairResult.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Aggregated result of an auto-repair run. +/// Whether all repair actions succeeded. +/// All individual repair actions. +/// Timestamp when the repair finished. +/// How long the full repair took in milliseconds. +public sealed record HealthRepairResult( + bool OverallSuccess, + IReadOnlyList Items, + DateTime CompletedAtUtc, + long DurationMs); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/HealthSeverity.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthSeverity.cs new file mode 100644 index 0000000..ed1ca37 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/HealthSeverity.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Overall severity level for a health check result. +public enum HealthSeverity +{ + /// No issues detected. + Ok, + + /// Non-critical issues that may degrade performance. + Warn, + + /// Critical issues that require immediate attention. + Error, +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/InstantSearchResult.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/InstantSearchResult.cs new file mode 100644 index 0000000..5bfa163 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/InstantSearchResult.cs @@ -0,0 +1,17 @@ +using System; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// +/// A minimal result record for the instant-search endpoint. +/// Short property names keep the JSON payload under 1 KB for typical result sets. +/// +public sealed record InstantSearchResult( + Guid Id, + string? Name, + string? Type, + int? Year, + string? Artists, + string? Album, + string? SeriesName, + double Relevance); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationProgress.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationProgress.cs new file mode 100644 index 0000000..ebf777b --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationProgress.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Immutable snapshot of migration progress returned to the API layer. +public sealed record MigrationProgress( + bool IsRunning, + bool IsCompleted, + bool HasError, + string? ErrorMessage, + int PercentComplete, + string CurrentTable, + long MigratedRows, + long TotalRows, + IReadOnlyList LogLines); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/SlowQueryInfo.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/SlowQueryInfo.cs new file mode 100644 index 0000000..309b19d --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/SlowQueryInfo.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Summary of a slow query captured from pg_stat_statements. +/// First 120 characters of the normalized query text. +/// Mean execution time in milliseconds. +/// Total number of executions. +/// Cumulative execution time in milliseconds. +/// Average rows returned per execution. +public sealed record SlowQueryInfo( + string QueryPreview, + double MeanMs, + long Calls, + double TotalMs, + double Rows); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/TableStats.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/TableStats.cs new file mode 100644 index 0000000..88cf4bb --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/TableStats.cs @@ -0,0 +1,9 @@ +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// Table size statistics returned by the maintenance API. +public sealed record TableStats( + string TableName, + long RowCount, + string TotalSize, + string TableSize, + string IndexSize); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/TargetColumn.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/TargetColumn.cs new file mode 100644 index 0000000..797ea91 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/TargetColumn.cs @@ -0,0 +1,4 @@ +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// Describes a target column with its PostgreSQL type and max length. +internal sealed record TargetColumn(string Name, string PgType, int? MaxLength = null); diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/TruncateResult.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/TruncateResult.cs new file mode 100644 index 0000000..b34afff --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/TruncateResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// +/// Result returned after truncating all Jellyfin data tables in PostgreSQL. +/// +/// Number of tables that were truncated. +/// Total rows that existed before truncation. +/// Per-table row counts before truncation. +public sealed record TruncateResult( + int TablesAffected, + long TotalRowsRemoved, + Dictionary RowCountsBeforeTruncation); diff --git a/Jellyfin.Database.Providers.Postgres/Services/PostgresRecommendationQueryProvider.cs b/Jellyfin.Database.Providers.Postgres/Services/PostgresRecommendationQueryProvider.cs new file mode 100644 index 0000000..5bac067 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/PostgresRecommendationQueryProvider.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Jellyfin.Plugin.JellyTrend.Api; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Optimised PostgreSQL implementation of . +/// Uses Npgsql directly (bypassing EF Core) to issue single-pass queries with native +/// PostgreSQL array operators (&&) and GIN-indexed lookups, replacing the +/// correlated-subquery pattern generated by EF Core that caused ~45 s response times. +/// +/// +/// +/// Genres, Tags and Studios are stored pipe-delimited (|) in BaseItems. +/// We convert them to arrays with string_to_array so the && +/// (overlap) operator can use GIN indexes. +/// +/// +/// This class is registered only when the Postgres plugin is active. If the user +/// has not installed JellyTrend, Jellyfin's DI container will not resolve +/// and this class is never instantiated. +/// +/// +public sealed class PostgresRecommendationQueryProvider : IRecommendationQueryProvider +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger for query diagnostics. + public PostgresRecommendationQueryProvider(ILogger logger) + { + _logger = logger; + } + + private static NpgsqlConnection OpenConnection() + { + var connStr = PostgresPlugin.Instance?.Configuration?.ConnectionString + ?? throw new InvalidOperationException("PostgreSQL connection string is not configured."); + var conn = new NpgsqlConnection(connStr); + conn.Open(); + return conn; + } + + /// + public IReadOnlyList GetPlayedMovies(Guid userId, int limit) + { + const string Sql = """ + SELECT b."Id", b."Genres", b."Tags", b."Studios", b."CommunityRating", b."PremiereDate", + p."ProviderValue" AS "TmdbId" + FROM "BaseItems" b + LEFT JOIN "BaseItemProviders" p ON p."ItemId" = b."Id" AND p."ProviderId" = 'Tmdb' + WHERE b."Type" = 'Movie' + AND b."IsVirtualItem" = false + AND EXISTS ( + SELECT 1 FROM "UserData" ud + WHERE ud."ItemId" = b."Id" + AND ud."UserId" = @userId + AND ud."Played" = true + ) + ORDER BY b."DateModified" DESC + LIMIT @limit + """; + + return ExecuteQuery(Sql, userId, limit); + } + + /// + public IReadOnlyList GetResumableMovies(Guid userId, int limit) + { + const string Sql = """ + SELECT b."Id", b."Genres", b."Tags", b."Studios", b."CommunityRating", b."PremiereDate", + p."ProviderValue" AS "TmdbId" + FROM "BaseItems" b + LEFT JOIN "BaseItemProviders" p ON p."ItemId" = b."Id" AND p."ProviderId" = 'Tmdb' + WHERE b."Type" = 'Movie' + AND b."IsVirtualItem" = false + AND EXISTS ( + SELECT 1 FROM "UserData" ud + WHERE ud."ItemId" = b."Id" + AND ud."UserId" = @userId + AND ud."PlaybackPositionTicks" > 0 + AND ud."Played" = false + ) + LIMIT @limit + """; + + return ExecuteQuery(Sql, userId, limit); + } + + /// + public IReadOnlyList GetUnwatchedMoviesByGenres( + Guid userId, + IReadOnlyList genres, + IReadOnlyList topParentIds, + int limit) + { + if (genres.Count == 0) + { + return []; + } + + var sql = BuildUnwatchedSql( + extraWhere: """ + AND string_to_array(b."Genres", '|') && @facets + """, + hasTopParents: topParentIds.Count > 0); + + return ExecuteUnwatchedQuery(sql, userId, genres.ToArray(), topParentIds, limit); + } + + /// + public IReadOnlyList GetUnwatchedMoviesByPersons( + Guid userId, + IReadOnlyList personIds, + IReadOnlyList topParentIds, + int limit) + { + if (personIds.Count == 0) + { + return []; + } + + // PeopleBaseItemMap joins on ItemId; filter by any of the given person ids. + var sql = BuildUnwatchedSql( + extraWhere: """ + AND EXISTS ( + SELECT 1 FROM "PeopleBaseItemMap" pm + WHERE pm."ItemId" = b."Id" + AND pm."PeopleId" = ANY(@facets) + ) + """, + hasTopParents: topParentIds.Count > 0, + facetType: NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid); + + return ExecuteUnwatchedQuery(sql, userId, personIds.ToArray(), topParentIds, limit); + } + + /// + public IReadOnlyList GetUnwatchedMoviesByTags( + Guid userId, + IReadOnlyList tags, + IReadOnlyList topParentIds, + int limit) + { + if (tags.Count == 0) + { + return []; + } + + var sql = BuildUnwatchedSql( + extraWhere: """ + AND string_to_array(b."Tags", '|') && @facets + """, + hasTopParents: topParentIds.Count > 0); + + return ExecuteUnwatchedQuery(sql, userId, tags.ToArray(), topParentIds, limit); + } + + /// + public IReadOnlyList GetRandomUnwatchedMovies( + Guid userId, + IReadOnlyList topParentIds, + int limit) + { + var topParentFilter = topParentIds.Count > 0 + ? "AND b.\"TopParentId\" = ANY(@topParentIds)" + : string.Empty; + + var sql = $""" + SELECT b."Id", b."Genres", b."Tags", b."Studios", b."CommunityRating", b."PremiereDate", + p."ProviderValue" AS "TmdbId" + FROM "BaseItems" b + LEFT JOIN "BaseItemProviders" p ON p."ItemId" = b."Id" AND p."ProviderId" = 'Tmdb' + WHERE b."Type" = 'Movie' + AND b."IsVirtualItem" = false + {topParentFilter} + AND NOT EXISTS ( + SELECT 1 FROM "UserData" ud + WHERE ud."ItemId" = b."Id" + AND ud."UserId" = @userId + AND (ud."Played" = true OR ud."PlaybackPositionTicks" > 0) + ) + ORDER BY random() + LIMIT @limit + """; + + try + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("userId", userId); + if (topParentIds.Count > 0) + { + cmd.Parameters.Add(new NpgsqlParameter("topParentIds", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid) + { + Value = topParentIds.ToArray() + }); + } + + cmd.Parameters.AddWithValue("limit", limit); + return ReadItems(cmd); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[JellyTrend] GetRandomUnwatchedMovies failed; returning empty list"); + return []; + } + } + + // ────────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────────── + + private static string BuildUnwatchedSql( + string extraWhere, + bool hasTopParents, + NpgsqlTypes.NpgsqlDbType facetType = NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Text) + { + _ = facetType; // consumed by caller via the overloaded ExecuteUnwatchedQuery + var topParentFilter = hasTopParents + ? "AND b.\"TopParentId\" = ANY(@topParentIds)" + : string.Empty; + + return $""" + SELECT b."Id", b."Genres", b."Tags", b."Studios", b."CommunityRating", b."PremiereDate", + p."ProviderValue" AS "TmdbId" + FROM "BaseItems" b + LEFT JOIN "BaseItemProviders" p ON p."ItemId" = b."Id" AND p."ProviderId" = 'Tmdb' + WHERE b."Type" = 'Movie' + AND b."IsVirtualItem" = false + {topParentFilter} + {extraWhere} + AND NOT EXISTS ( + SELECT 1 FROM "UserData" ud + WHERE ud."ItemId" = b."Id" + AND ud."UserId" = @userId + AND (ud."Played" = true OR ud."PlaybackPositionTicks" > 0) + ) + ORDER BY b."CommunityRating" DESC NULLS LAST + LIMIT @limit + """; + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL is a hardcoded constant string — no user input reaches CommandText.")] + private List ExecuteQuery(string sql, Guid userId, int limit) + { + try + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("userId", userId); + cmd.Parameters.AddWithValue("limit", limit); + return ReadItems(cmd); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[JellyTrend] Query failed; returning empty list. SQL: {Sql}", sql); + return []; + } + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL is built from hardcoded templates only; no user input reaches CommandText.")] + private List ExecuteUnwatchedQuery( + string sql, + Guid userId, + Array facets, + IReadOnlyList topParentIds, + int limit) + { + try + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("userId", userId); + + // Facet parameter type depends on the caller (string[] or Guid[]). + if (facets is string[] strFacets) + { + cmd.Parameters.Add(new NpgsqlParameter("facets", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Text) { Value = strFacets }); + } + else if (facets is Guid[] guidFacets) + { + cmd.Parameters.Add(new NpgsqlParameter("facets", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid) { Value = guidFacets }); + } + + if (topParentIds.Count > 0) + { + cmd.Parameters.Add(new NpgsqlParameter("topParentIds", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid) + { + Value = topParentIds.ToArray() + }); + } + + cmd.Parameters.AddWithValue("limit", limit); + return ReadItems(cmd); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[JellyTrend] Unwatched query failed; returning empty list. SQL: {Sql}", sql); + return []; + } + } + + private static List ReadItems(NpgsqlCommand cmd) + { + var results = new List(); + using var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess); + while (reader.Read()) + { + var id = reader.GetGuid(0); + var genres = SplitPipeDelimited(reader.IsDBNull(1) ? null : reader.GetString(1)); + var tags = SplitPipeDelimited(reader.IsDBNull(2) ? null : reader.GetString(2)); + var studios = SplitPipeDelimited(reader.IsDBNull(3) ? null : reader.GetString(3)); + float? rating = reader.IsDBNull(4) ? null : reader.GetFloat(4); + DateTime? premiereDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5).ToUniversalTime(); + string? tmdbId = reader.IsDBNull(6) ? null : reader.GetString(6); + + results.Add(new RecommendationItem(id, tmdbId, genres, tags, studios, rating, premiereDate)); + } + + return results; + } + + private static string[] SplitPipeDelimited(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/SecurityElementHelper.cs b/Jellyfin.Database.Providers.Postgres/Services/SecurityElementHelper.cs new file mode 100644 index 0000000..9112cf8 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/SecurityElementHelper.cs @@ -0,0 +1,29 @@ +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Shim for System.Security.SecurityElement.Escape — replaces XML special +/// characters with their entity equivalents. Used when writing database.xml. +/// +internal static class SecurityElementHelper +{ + /// + /// Escapes XML special characters in . + /// Returns if is . + /// + /// The text to escape. + /// The escaped text, or . + internal static string? Escape(string? text) + { + if (text is null) + { + return null; + } + + return text + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal) + .Replace("'", "'", StringComparison.Ordinal); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs new file mode 100644 index 0000000..77976a6 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs @@ -0,0 +1,143 @@ +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// EF Core command interceptor that rewrites plain INSERT statements on tables +/// with known composite-primary-key constraints into +/// INSERT … ON CONFLICT DO NOTHING. +/// +/// +/// +/// Jellyfin's BaseItemRepository.UpdateOrInsertItems generates INSERT +/// statements for junction tables such as BaseItemProviders without checking +/// for pre-existing rows first. In SQLite this silently succeeded (implicit REPLACE +/// semantics), but PostgreSQL enforces PK uniqueness strictly and raises error 23505, +/// which propagates as an unhandled exception that crashes the server when triggered +/// from a plugin timer (e.g. TMDbBoxSets AddMoviesToCollection). +/// +/// +/// The rewrite is idempotent: if the row already exists the insert is skipped, +/// preserving the same effective behaviour as the original SQLite implementation. +/// +/// +public sealed partial class UpsertConflictInterceptor : DbCommandInterceptor +{ + // All junction / mapping tables where Jellyfin may attempt a duplicate INSERT + // during a library refresh or plugin-triggered collection update. + private static readonly string[] TargetTables = + [ + "\"BaseItemProviders\"", + "\"BaseItemImageInfos\"", + "\"AncestorIds\"", + "\"ItemValuesMap\"", + "\"PeopleBaseItemMap\"", + "\"BaseItemTrailerTypes\"", + "\"BaseItemMetadataFields\"", + ]; + + /// + public override InterceptionResult NonQueryExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + RewriteCommand(command); + return result; + } + + /// + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + RewriteCommand(command); + return ValueTask.FromResult(result); + } + + /// + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + RewriteCommand(command); + return result; + } + + /// + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + RewriteCommand(command); + return ValueTask.FromResult(result); + } + + // ── Rewrite logic ───────────────────────────────────────────────────────── + + private static void RewriteCommand(DbCommand command) + { + var sql = command.CommandText; + + if (!sql.Contains("INSERT INTO", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!TargetTables.Any(t => sql.Contains(t, StringComparison.Ordinal))) + { + return; + } + + // Delegate the actual CommandText assignment to a separate method so that + // the CA3001 suppression is scoped only to where the assignment occurs. + ApplyRewrite(command, sql); + } + + // CA2100 / CA3001: CommandText is built by EF Core's internal model rewriter. + // The Regex only appends the literal string "ON CONFLICT DO NOTHING" to statements + // that EF Core itself generated — no user-supplied HTTP input ever reaches this path. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Rewritten text appends a hardcoded literal to EF Core-generated SQL. No user input reaches this path.")] + [SuppressMessage("Security", "CA3001:Review code for SQL injection vulnerabilities", Justification = "Rewritten text appends a hardcoded literal to EF Core-generated SQL. No user input reaches this path.")] + private static void ApplyRewrite(DbCommand command, string sql) + { + command.CommandText = InsertStatementRegex().Replace(sql, static match => + { + var tableName = match.Groups[1].Value; + + if (!TargetTables.Any(t => string.Equals(tableName, t, StringComparison.Ordinal))) + { + return match.Value; + } + + // Already has ON CONFLICT — don't double-append. + if (match.Value.Contains("ON CONFLICT", StringComparison.OrdinalIgnoreCase)) + { + return match.Value; + } + + var stmt = match.Value.TrimEnd(); + return stmt.EndsWith(';') + ? stmt[..^1] + "\nON CONFLICT DO NOTHING;" + : stmt + "\nON CONFLICT DO NOTHING"; + }); + } + + /// + /// Matches a single INSERT statement targeting a quoted table name, + /// including its VALUES clause and trailing semicolon. + /// Group 1 captures the quoted table name. + /// + [GeneratedRegex( + @"INSERT\s+INTO\s+(""[^""]+"")[\s\S]*?VALUES\s*\([^;]*\)\s*;", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex InsertStatementRegex(); +} diff --git a/Jellyfin.Database.Providers.Postgres/Tasks/BackupTask.cs b/Jellyfin.Database.Providers.Postgres/Tasks/BackupTask.cs index 59fe320..8b53d2a 100644 --- a/Jellyfin.Database.Providers.Postgres/Tasks/BackupTask.cs +++ b/Jellyfin.Database.Providers.Postgres/Tasks/BackupTask.cs @@ -17,7 +17,11 @@ public class BackupTask : IScheduledTask private readonly MaintenanceService _maintenance; private readonly IApplicationPaths _applicationPaths; - /// Initializes a new instance of . + /// + /// Initializes a new instance of the class. + /// + /// The maintenance service. + /// Jellyfin application paths. public BackupTask(MaintenanceService maintenance, IApplicationPaths applicationPaths) { _maintenance = maintenance; @@ -64,7 +68,7 @@ await _maintenance.CreateBackupAsync( config.ConnectionString, backupDir, config.BackupCompression, - config.PgDumpPath, + config.PgBinPath, cancellationToken).ConfigureAwait(false); progress.Report(100); } diff --git a/Jellyfin.Database.Providers.Postgres/Tasks/OptimizeIndexesTask.cs b/Jellyfin.Database.Providers.Postgres/Tasks/OptimizeIndexesTask.cs new file mode 100644 index 0000000..280c44c --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Tasks/OptimizeIndexesTask.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Postgres.Tasks; + +/// +/// Scheduled task that applies (or re-applies) all PostgreSQL performance optimizations: +/// pg_trgm GIN indexes for near-instant search and aggressive autovacuum settings on +/// high-churn tables (UserData, ActivityLogs, BaseItems). +/// +/// All work is idempotent — safe to run multiple times. Index creation uses +/// CREATE INDEX CONCURRENTLY IF NOT EXISTS so there are no table locks. +/// +public sealed class OptimizeIndexesTask : IScheduledTask +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public OptimizeIndexesTask(ILogger logger) + { + _logger = logger; + } + + /// + public string Name => "PostgreSQL Apply Performance Optimizations"; + + /// + public string Key => "PostgresOptimizeIndexes"; + + /// + public string Description => + "Creates pg_trgm GIN indexes for near-instant text search and tunes autovacuum " + + "on high-churn tables. Safe to run multiple times — all statements are idempotent."; + + /// + public string Category => "PostgreSQL Maintenance"; + + /// + public IEnumerable GetDefaultTriggers() + { + // Run once a week on Saturday at 01:00 to pick up any missed startup run + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.WeeklyTrigger, + DayOfWeek = DayOfWeek.Saturday, + TimeOfDayTicks = TimeSpan.FromHours(1).Ticks, + }; + } + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + var config = PostgresPlugin.Instance?.Configuration; + var connStr = config?.ConnectionString; + + if (string.IsNullOrWhiteSpace(connStr)) + { + return; + } + + progress.Report(5); + + await PostgresDatabaseProvider.RunOptimizationsAsync( + connStr, + enableSearch: config?.EnableSearchOptimizations ?? true, + enableVacuum: config?.EnableAutovacuumTuning ?? true, + logger: _logger, + ct: cancellationToken).ConfigureAwait(false); + + progress.Report(100); + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Tasks/ReindexTask.cs b/Jellyfin.Database.Providers.Postgres/Tasks/ReindexTask.cs index 99865db..68fb122 100644 --- a/Jellyfin.Database.Providers.Postgres/Tasks/ReindexTask.cs +++ b/Jellyfin.Database.Providers.Postgres/Tasks/ReindexTask.cs @@ -15,7 +15,10 @@ public class ReindexTask : IScheduledTask { private readonly MaintenanceService _maintenance; - /// Initializes a new instance of . + /// + /// Initializes a new instance of the class. + /// + /// The maintenance service. public ReindexTask(MaintenanceService maintenance) => _maintenance = maintenance; diff --git a/Jellyfin.Database.Providers.Postgres/Tasks/VacuumAnalyzeTask.cs b/Jellyfin.Database.Providers.Postgres/Tasks/VacuumAnalyzeTask.cs index abbc7fd..5da69aa 100644 --- a/Jellyfin.Database.Providers.Postgres/Tasks/VacuumAnalyzeTask.cs +++ b/Jellyfin.Database.Providers.Postgres/Tasks/VacuumAnalyzeTask.cs @@ -15,7 +15,10 @@ public class VacuumAnalyzeTask : IScheduledTask { private readonly MaintenanceService _maintenance; - /// Initializes a new instance of . + /// + /// Initializes a new instance of the class. + /// + /// The maintenance service. public VacuumAnalyzeTask(MaintenanceService maintenance) => _maintenance = maintenance; diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index 11a3a69..9d130e7 100644 --- a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html +++ b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html @@ -1,5 +1,5 @@ - + PostgreSQL Database Provider
+ + + + +
⚠️Confirmar acción
+
+

+
+
+

PostgreSQL Database Provider

-
+
@@ -186,12 +251,8 @@

Backup y

- -
Ruta persistente para backups manuales y tareas programadas.
-
-
- -
Ruta persistente para restores desde el panel.
+ +
Carpeta que contiene pg_dump, psql, etc. Ej: C:\Program Files\PostgreSQL\17\bin  |  /usr/lib/postgresql/17/bin  |  Vacío = auto-detectar
+ + +
+

Exportar PostgreSQL → SQLite

+

+ Escribe todos los datos de PostgreSQL directamente en un archivo .db de SQLite. + Util para recuperar la base de datos original o revertir a SQLite. +

+
+ 📄 Si el archivo .db destino no existe, se crea automáticamente con el schema oficial de Jellyfin usando EF Core Migrations.
+ Si ya existe, se te preguntará si deseas sobreescribirlo o hacer un backup (.bkp) antes de continuar. +
+
+ +
Se auto-detecta la ruta nativa de Jellyfin. Puedes cambiarla antes de iniciar.
+
+ +
+ + +
@@ -303,19 +412,41 @@

Operaciones de mantenimiento

-

Backups PostgreSQL

-
- -
+

Backups PostgreSQL

Esta seccion usa la configuracion persistida en la pestana Configuracion (directorio y rutas de binarios).
-
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ Manual path (advanced) +
+ +
+
+ +
@@ -330,6 +461,23 @@

Backups Postgre

+ +
+

Optimizacion de rendimiento

+

Crea indices GIN trigram (pg_trgm) para busqueda instantanea y aplica ajustes de autovacuum en tablas criticas. Idempotente: se puede ejecutar varias veces sin daño.

+ + + +
+ + +
+ + + + @@ -366,6 +534,7 @@

Estado de la base de datos

tab_config: 'Configuration', tab_migration: 'Migration', tab_maintenance: 'Maintenance', + tab_health: 'Health', heading_status: 'Current status', heading_connection: 'PostgreSQL connection', heading_activate: 'Activate PostgreSQL', @@ -382,18 +551,45 @@

Estado de la base de datos

button_vacuum: 'VACUUM ANALYZE', button_reindex: 'REINDEX DATABASE', button_refresh_stats: 'Refresh statistics', + heading_opt: 'Performance optimization', + desc_opt: 'Creates GIN trigram indexes (pg_trgm) for instant search and tunes autovacuum on critical tables. Idempotent — safe to run multiple times.', + button_apply_opt: '\u26A1 Apply optimizations', + msg_opt_running: 'Applying optimizations in background...', + msg_opt_done: 'Optimization job started. GIN indexes may take a few minutes on large databases.', heading_backup: 'PostgreSQL backups', heading_config_backup: 'Backup and restore (persistent configuration)', field_backup_directory: 'Backup directory', field_backup_directory_default: 'Default backup directory', - field_pgdump_path: 'pg_dump path (optional)', - field_pgrestore_path: 'psql path (optional)', + field_pgbin_path: 'PostgreSQL bin directory (optional)', field_restore_backup_path: 'Backup path to restore (.sql or .zip)', label_backup_compress: 'Compress backup as ZIP', label_backup_compress_default: 'Compress backup as ZIP by default', button_create_backup: 'Create backup now', button_restore_backup: 'Restore backup', + backup_selector_label: 'Select backup to restore', + backup_selector_empty: '\u2014 no backups found \u2014', + backup_selector_loading: 'Loading backups...', + backup_manual_path: 'Manual path (advanced)', + button_refresh_backups: 'Refresh', + backup_meta_size: 'Size: ', + backup_meta_date: ' | Date: ', + msg_backup_done_size: 'Backup created: ', + msg_restore_done_size: 'Restore completed from: ', help_password_mask: 'Only the password is masked. Other fields are visible.', + tab_export: 'Export to SQLite', + heading_export: 'Export PostgreSQL \u2192 SQLite', + desc_export: 'Writes all PostgreSQL data directly into a SQLite .db file. Useful to recover the original database or revert to SQLite.', + hint_export: '📄 If the .db file does not exist, it is created from the PostgreSQL schema. If it already exists, its structure is preserved and only the data is replaced.', + field_export_target_path: 'Target .db file path', + help_export_target_path: 'Auto-detected native Jellyfin path. You can change it before starting.', + button_start_export: 'Start export to SQLite', + heading_export_progress: 'Export progress', + msg_export_running: 'Export in progress...', + msg_export_conflict: 'An export is already in progress.', + msg_export_start_error: 'Error starting export: ', + msg_export_done_prefix: 'Export completed: ', + msg_export_table_prefix: 'Current table: ', + msg_export_rows_prefix: 'Exported rows: ', summary_advanced_options: 'Advanced options', help_pool_size: 'Maximum number of connections in the Npgsql pool.', desc_activate: 'When migration is complete, write database.xml and restart Jellyfin.', @@ -401,12 +597,18 @@

Estado de la base de datos

hint_use_saved_connection: '🔗 Use the connection configured in the Configuration tab. Save configuration before starting migration.', help_sqlite_path: 'Auto-detected when the file exists in Jellyfin data directory.', label_truncate: 'Truncate tables before insert (--truncate)', + truncate_heading: 'Prepare PostgreSQL for clean migration', + truncate_desc: 'If PostgreSQL already has previous data (re-migration), clean it first to avoid PK conflicts.', + truncate_note: 'Deletes all data but preserves the schema. Irreversible without a backup.', + button_truncate_pg: 'Clean PostgreSQL (TRUNCATE ALL)', + msg_truncate_confirm: 'WARNING: This will delete ALL Jellyfin data in PostgreSQL.\n\nThe schema is preserved. This operation is irreversible without a prior backup.\n\nConfirm you want to clean PostgreSQL?', + msg_truncate_running: 'Cleaning...', + msg_truncate_done: ' tables cleaned.', + msg_truncate_rows: ' rows deleted.', hint_migration_completed_activate: 'Migration completed. You can activate PostgreSQL now.', desc_maintenance_only_active: 'These operations are only available when PostgreSQL is active.', - help_pgdump_path: 'If empty, pg_dump is resolved from system PATH.', - help_pgrestore_path: 'If empty, psql is resolved from system PATH.', - help_config_pgdump_path: 'Persistent path for manual backups and scheduled tasks.', - help_config_pgrestore_path: 'Persistent path for restore operations from the panel.', + + help_config_pgbin_path: 'Directory containing pg_dump, psql, etc. Example: C:\\Program Files\\PostgreSQL\\17\\bin — Leave empty to auto-detect.', help_maintenance_config_hint: 'This section uses persisted settings from the Configuration tab (directory and binary paths).', hint_scheduled_tasks: '📅 These operations are also available as Jellyfin scheduled tasks so you can configure automatic execution.', link_scheduled_tasks: 'Go to Scheduled Tasks →', @@ -461,13 +663,29 @@

Estado de la base de datos

msg_connection_prefix: 'Connection: ', msg_db_size_prefix: 'DB size: ', msg_active_connections_prefix: 'Active connections: ', - msg_unknown_error: 'unknown error' + msg_unknown_error: 'unknown error', + heading_health: 'Database health', + desc_health: 'Diagnoses invalid indexes, table bloat, out-of-sync sequences, stale statistics and unused indexes. Read-only.', + button_health_check: 'Run health check', + button_health_repair: 'Auto-repair', + msg_health_running: 'Running health check...', + msg_health_repair_running: 'Repairing database...', + msg_health_repair_done_prefix: 'Auto-repair finished: ', + msg_health_repair_actions_suffix: ' actions (', + msg_health_repair_ok: ' ok, ', + msg_health_repair_failed: ' failed).', + msg_health_findings_suffix: ' finding(s)', + msg_health_repairable_tag: '[auto-repairable]', + health_sev_ok: 'All checks passed', + health_sev_warn: 'Warnings detected', + health_sev_error: 'Critical issues detected' }, es: { title_main: 'PostgreSQL Database Provider', tab_config: 'Configuracion', tab_migration: 'Migracion', tab_maintenance: 'Mantenimiento', + tab_health: 'Health', heading_status: 'Estado actual', heading_connection: 'Conexion PostgreSQL', heading_activate: 'Activar PostgreSQL', @@ -484,17 +702,44 @@

Estado de la base de datos

button_vacuum: 'VACUUM ANALYZE', button_reindex: 'REINDEX DATABASE', button_refresh_stats: 'Actualizar estadisticas', + heading_opt: 'Optimizacion de rendimiento', + desc_opt: 'Crea indices GIN trigram (pg_trgm) para busqueda instantanea y ajusta autovacuum en tablas criticas. Idempotente: se puede ejecutar varias veces sin daño.', + button_apply_opt: '\u26A1 Aplicar optimizaciones', + msg_opt_running: 'Aplicando optimizaciones en segundo plano...', + msg_opt_done: 'Tarea de optimizacion iniciada. Los indices GIN pueden tardar unos minutos en bases de datos grandes.', heading_backup: 'Backups PostgreSQL', heading_config_backup: 'Backup y Restore (configuracion persistente)', field_backup_directory: 'Directorio de backup', field_backup_directory_default: 'Directorio de backup por defecto', - field_pgdump_path: 'Ruta de pg_dump (opcional)', - field_pgrestore_path: 'Ruta de psql (opcional)', + field_pgbin_path: 'Directorio de binarios PostgreSQL (opcional)', field_restore_backup_path: 'Ruta del backup a restaurar (.sql o .zip)', label_backup_compress: 'Comprimir backup en ZIP', label_backup_compress_default: 'Comprimir backup en ZIP por defecto', button_create_backup: 'Crear backup ahora', - button_restore_backup: 'Restablecer backup', + button_restore_backup: 'Restaurar backup', + backup_selector_label: 'Seleccionar backup a restaurar', + backup_selector_empty: '\u2014 sin backups encontrados \u2014', + backup_selector_loading: 'Cargando backups...', + backup_manual_path: 'Ruta manual (avanzado)', + button_refresh_backups: 'Actualizar', + backup_meta_size: 'Tama\u00F1o: ', + backup_meta_date: ' | Fecha: ', + msg_backup_done_size: 'Backup creado: ', + msg_restore_done_size: 'Restauraci\u00F3n completada desde: ', + tab_export: 'Exportar a SQLite', + heading_export: 'Exportar PostgreSQL \u2192 SQLite', + desc_export: 'Escribe todos los datos de PostgreSQL directamente en un archivo SQLite .db. Util para recuperar la base de datos original o revertir a SQLite.', + hint_export: '📄 Si el archivo .db no existe, se crea con el schema derivado de PostgreSQL. Si ya existe, se respetan las tablas y se reemplaza solo el contenido.', + field_export_target_path: 'Ruta del archivo .db destino', + help_export_target_path: 'Se auto-detecta la ruta nativa de Jellyfin. Puedes cambiarla antes de iniciar.', + button_start_export: 'Iniciar exportacion a SQLite', + heading_export_progress: 'Progreso de exportacion', + msg_export_running: 'Exportacion en progreso...', + msg_export_conflict: 'Ya hay una exportacion en curso.', + msg_export_start_error: 'Error al iniciar exportacion: ', + msg_export_done_prefix: 'Exportacion completada: ', + msg_export_table_prefix: 'Tabla actual: ', + msg_export_rows_prefix: 'Filas exportadas: ', help_password_mask: 'Solo la contrasena se enmascara. El resto de los campos son visibles.', summary_advanced_options: 'Opciones avanzadas', help_pool_size: 'Numero maximo de conexiones en el pool de Npgsql.', @@ -503,12 +748,18 @@

Estado de la base de datos

hint_use_saved_connection: '🔗 Usa la conexion configurada en la pestana Configuracion. Guarda la configuracion antes de iniciar la migracion.', help_sqlite_path: 'Se auto-detecta si el archivo existe en el directorio de datos de Jellyfin.', label_truncate: 'Truncar tablas antes de insertar (--truncate)', + truncate_heading: 'Preparar PostgreSQL para migraci\u00F3n limpia', + truncate_desc: 'Si PostgreSQL ya tiene datos previos (re-migraci\u00F3n), l\u00EDmpialo primero para evitar conflictos de PK.', + truncate_note: 'Elimina todos los datos pero conserva el schema. Irreversible sin backup.', + button_truncate_pg: 'Limpiar PostgreSQL (TRUNCATE ALL)', + msg_truncate_confirm: 'ADVERTENCIA: Esto eliminar\u00E1 TODOS los datos de Jellyfin en PostgreSQL.\n\nEl schema se conserva. Esta operaci\u00F3n es irreversible sin un backup previo.\n\n\u00BFConfirmas que quieres limpiar PostgreSQL?', + msg_truncate_running: 'Limpiando...', + msg_truncate_done: ' tablas limpiadas.', + msg_truncate_rows: ' filas eliminadas.', hint_migration_completed_activate: 'Migracion completada. Ahora puedes activar PostgreSQL.', desc_maintenance_only_active: 'Estas operaciones solo estan disponibles cuando PostgreSQL esta activo.', - help_pgdump_path: 'Si se deja vacio, se usa pg_dump desde PATH del sistema.', - help_pgrestore_path: 'Si se deja vacio, se usa psql desde PATH del sistema.', - help_config_pgdump_path: 'Ruta persistente para backups manuales y tareas programadas.', - help_config_pgrestore_path: 'Ruta persistente para restores desde el panel.', + + help_config_pgbin_path: 'Carpeta con pg_dump, psql, etc. Ej: C:\\Program Files\\PostgreSQL\\17\\bin — Vac\u00EDo = auto-detectar.', help_maintenance_config_hint: 'Esta seccion usa la configuracion persistida en la pestana Configuracion (directorio y rutas de binarios).', hint_scheduled_tasks: '📅 Estas operaciones tambien estan registradas como tareas programadas de Jellyfin para que puedas configurar su horario automatico.', link_scheduled_tasks: 'Ir a Tareas programadas →', @@ -563,7 +814,22 @@

Estado de la base de datos

msg_connection_prefix: 'Conexion: ', msg_db_size_prefix: 'Tamano de BD: ', msg_active_connections_prefix: 'Conexiones activas: ', - msg_unknown_error: 'error desconocido' + msg_unknown_error: 'error desconocido', + heading_health: 'Salud de la base de datos', + desc_health: 'Diagnostica indices invalidos, bloat, secuencias desincronizadas, estadisticas obsoletas e indices sin uso. Solo lectura.', + button_health_check: 'Ejecutar Health Check', + button_health_repair: 'Auto-repair', + msg_health_running: 'Ejecutando health check...', + msg_health_repair_running: 'Reparando la base de datos...', + msg_health_repair_done_prefix: 'Auto-repair completado: ', + msg_health_repair_actions_suffix: ' acciones (', + msg_health_repair_ok: ' ok, ', + msg_health_repair_failed: ' fallaron).', + msg_health_findings_suffix: ' hallazgo(s)', + msg_health_repairable_tag: '[auto-reparable]', + health_sev_ok: 'Todos los checks pasaron', + health_sev_warn: 'Advertencias detectadas', + health_sev_error: 'Problemas criticos detectados' } }; @@ -644,10 +910,13 @@

Estado de la base de datos

setHtml('migrationConfigHint', t('hint_use_saved_connection', '🔗 Use the connection configured in the Configuration tab. Save configuration before starting migration.')); setText('sqlitePathHelp', t('help_sqlite_path', 'Auto-detected when the file exists in Jellyfin data directory.')); setText('truncateLabel', t('label_truncate', 'Truncate tables before insert (--truncate)')); + setText('truncateHeading', t('truncate_heading', 'Prepare PostgreSQL for clean migration')); + setText('truncateDesc', t('truncate_desc', 'If PostgreSQL already has previous data (re-migration), clean it first to avoid PK conflicts.')); + setText('truncateNote', t('truncate_note', 'Deletes all data but preserves the schema. Irreversible without a backup.')); + setText('btnTruncatePgLabel', t('button_truncate_pg', 'Clean PostgreSQL (TRUNCATE ALL)')); setText('migrationCompleteHint', t('hint_migration_completed_activate', 'Migration completed. You can activate PostgreSQL now.')); setText('maintenanceDescription', t('desc_maintenance_only_active', 'These operations are only available when PostgreSQL is active.')); - setText('configPgDumpHelp', t('help_config_pgdump_path', 'Persistent path for manual backups and scheduled tasks.')); - setText('configPgRestoreHelp', t('help_config_pgrestore_path', 'Persistent path for restore operations from the panel.')); + setText('configPgBinHelp', t('help_config_pgbin_path', 'Directory containing pg_dump, psql, etc. Leave empty to auto-detect.')); setText('maintenanceConfigHint', t('help_maintenance_config_hint', 'This section uses persisted settings from the Configuration tab (directory and binary paths).')); setHtml('scheduledTasksHintText', t('hint_scheduled_tasks', '📅 These operations are also available as Jellyfin scheduled tasks so you can configure automatic execution.')); setHtml('scheduledTasksLink', t('link_scheduled_tasks', 'Go to Scheduled Tasks →')); @@ -666,8 +935,28 @@

Estado de la base de datos

setText('btnVacuum', t('button_vacuum', 'VACUUM ANALYZE')); setText('btnReindex', t('button_reindex', 'REINDEX DATABASE')); setText('btnRefreshStats', t('button_refresh_stats', 'Refresh statistics', 'ButtonRefresh')); - setText('btnCreateBackup', t('button_create_backup', 'Create backup now')); - setText('btnRestoreBackup', t('button_restore_backup', 'Restore backup')); + setText('optHeading', t('heading_opt', 'Performance optimization')); + setText('optDescription', t('desc_opt', 'Creates GIN trigram indexes (pg_trgm) for instant search and tunes autovacuum on critical tables.')); + setText('btnApplyOptimizations', t('button_apply_opt', '\u26A1 Apply optimizations')); + setText('healthHeading', t('heading_health', 'Database health')); + setText('healthDescription', t('desc_health', 'Diagnoses invalid indexes, table bloat, out-of-sync sequences, stale statistics and unused indexes. Read-only.')); + setText('btnHealthCheckLabel', t('button_health_check', 'Run health check')); + setText('btnHealthRepairLabel', t('button_health_repair', 'Auto-repair')); + setText('btnCreateBackupLabel', t('button_create_backup', 'Create backup now')); + setText('btnRestoreBackupLabel', t('button_restore_backup', 'Restore backup')); + setText('backupSelectorLabel', t('backup_selector_label', 'Select backup to restore')); + setText('backupSelectorEmpty', t('backup_selector_empty', '\u2014 no backups found \u2014')); + setText('backupManualPathSummary', t('backup_manual_path', 'Manual path (advanced)')); + setText('btnRefreshBackupsLabel', t('button_refresh_backups', 'Refresh')); + setInputLabel('restoreBackupPath', t('field_restore_backup_path', 'Backup path (.sql or .zip)')); + + setText('exportHeading', t('heading_export', 'Export PostgreSQL \u2192 SQLite')); + setHtml('exportDescription', t('desc_export', 'Writes all PostgreSQL data directly into a SQLite .db file.')); + setHtml('exportHint', t('hint_export', '📄 If the .db file does not exist, it is created from the PostgreSQL schema. If it already exists, its structure is preserved and only the data is replaced.')); + setText('exportProgressHeading', t('heading_export_progress', 'Export progress')); + setText('btnStartExport', t('button_start_export', 'Start export to SQLite')); + setInputLabel('exportTargetPath', t('field_export_target_path', 'Target .db file path')); + setText('exportTargetPathHelp', t('help_export_target_path', 'Auto-detected native Jellyfin path. You can change it before starting.')); setInputLabel('pgHost', t('field_host', 'Host', 'LabelHost')); setInputLabel('pgPort', t('field_port', 'Port', 'LabelPort')); @@ -679,8 +968,7 @@

Estado de la base de datos

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')); - setInputLabel('configPgDumpPath', t('field_pgdump_path', 'pg_dump path (optional)')); - setInputLabel('configPgRestorePath', t('field_pgrestore_path', 'psql path (optional)')); + setInputLabel('configPgBinPath', t('field_pgbin_path', 'PostgreSQL bin directory (optional)')); var tabs = document.querySelectorAll('.pg-tab-btn'); if (tabs.length >= 3) { @@ -688,7 +976,17 @@

Estado de la base de datos

tabs[1].innerHTML = '🔄 ' + t('tab_migration', 'Migration', 'HeaderMigrate'); tabs[2].innerHTML = '🔧 ' + t('tab_maintenance', 'Maintenance', 'HeaderMaintenance'); } - } + if (tabs.length >= 4) { + tabs[3].innerHTML = '💊 ' + t('tab_health', 'Health'); + } + + 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.')); + setHtml('exportHint', t('hint_export', '📄 If the .db file does not exist, it is created from the PostgreSQL schema. If it already exists, its structure is preserved and only the data is replaced.')); + setText('exportProgressHeading', t('heading_export_progress', 'Export progress')); + setText('btnStartExport', t('button_start_export', 'Start export to SQLite')); + setInputLabel('exportTargetPath', t('field_export_target_path', 'Target .db file path')); + setText('exportTargetPathHelp', t('help_export_target_path', 'Auto-detected native Jellyfin path. You can change it before starting.')); } function parseConnStr(s) { var result = {}; @@ -740,7 +1038,33 @@

Estado de la base de datos

}); btn.classList.remove('button-default'); btn.classList.add('button-submit'); - if (target === 'maintenance') loadStats(); + if (target === 'maintenance') { loadStats(); loadBackupList(); } + if (target === 'health') { loadHealthCheck(); } + if (target === 'migration') { + // Pre-fill export target path with the auto-detected native SQLite path + var exportField = document.getElementById('exportTargetPath'); + if (exportField && !exportField.value) { + apiGet('/ExportToSqliteDefaultPath') + .then(function(r) { return r.json(); }) + .then(function(d) { if (d && d.path) exportField.value = d.path; }) + .catch(function() {}); + } + // Recover export status if a task is running or just finished + apiGet('/ExportToSqliteProgress') + .then(function(r) { return r.json(); }) + .then(function(raw) { + var isRunning = raw.IsRunning !== undefined ? raw.IsRunning : raw.isRunning; + var isCompleted = raw.IsCompleted !== undefined ? raw.IsCompleted : raw.isCompleted; + var hasError = raw.HasError !== undefined ? raw.HasError : raw.hasError; + if (isRunning || isCompleted || hasError) { + document.getElementById('exportProgressSection').style.display = ''; + document.getElementById('btnStartExport').disabled = !!isRunning; + updateExportProgress(raw); + if (isRunning) startExportPoll(); + } + }) + .catch(function() {}); + } }); }); @@ -775,17 +1099,48 @@

Estado de la base de datos

banner.style.display = 'none'; } - function showConfirm(message, onConfirm) { - var dialog = document.getElementById('pgConfirmDialog'); - var text = document.getElementById('pgConfirmText'); - var yes = document.getElementById('pgConfirmYes'); - var no = document.getElementById('pgConfirmNo'); - text.textContent = message; - yes.textContent = t('button_confirm', 'Confirm'); - no.textContent = t('button_cancel', 'Cancel'); - dialog.style.display = ''; - yes.onclick = function() { dialog.style.display = 'none'; onConfirm(); }; - no.onclick = function() { dialog.style.display = 'none'; }; + // Uses the native HTML5 element which works correctly + // inside Jellyfin's plugin iframe without needing RequireJS modules. + var _dlg = document.getElementById('pgNativeDialog'); + var _dlgText = document.getElementById('pgDlgText'); + var _dlgAct = document.getElementById('pgDlgActions'); + + // Close on backdrop click + _dlg.addEventListener('click', function(e) { + var r = _dlg.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || + e.clientY < r.top || e.clientY > r.bottom) { + _dlg.close('cancel'); + } + }); + + // Button palette matching the plugin colour system + var DLG_COLORS = { + cancel: 'background:rgba(60,72,100,.85);color:#c8d4ee;', + confirm: 'background:linear-gradient(135deg,#3a6dd8,#5a8ff0);color:#fff;', + danger: 'background:linear-gradient(135deg,#b71c1c,#e53935);color:#fff;', + warning: 'background:linear-gradient(135deg,#e65100,#f57c00);color:#fff;', + safe: 'background:linear-gradient(135deg,#1b5e20,#388e3c);color:#fff;', + }; + + function makeDlgBtn(label, colorKey, closeVal) { + var b = document.createElement('button'); + b.textContent = label; + b.style.cssText = DLG_COLORS[colorKey] || DLG_COLORS.confirm; + b.onclick = function() { _dlg.close(closeVal); }; + return b; + } + + function showConfirm(message, onConfirm, opts) { + opts = opts || {}; + document.getElementById('pgDlgIcon').textContent = opts.icon || '\u26a0\ufe0f'; + document.getElementById('pgDlgTitleText').textContent = opts.title || t('msg_confirm', 'Confirmar acci\u00f3n'); + _dlgText.textContent = message; + _dlgAct.innerHTML = ''; + _dlgAct.appendChild(makeDlgBtn(t('button_cancel', 'Cancelar'), 'cancel', 'no')); + _dlgAct.appendChild(makeDlgBtn(opts.confirmLabel || t('button_confirm', 'Confirmar'), opts.confirmColor || 'confirm', 'yes')); + _dlg.onclose = function() { if (_dlg.returnValue === 'yes') { onConfirm(); } }; + _dlg.showModal(); } function loadStatus() { @@ -813,7 +1168,7 @@

Estado de la base de datos

} fillFieldsFromConnStr(data.SavedConnectionString); - if (data.SavedCommandTimeout) document.getElementById('pgCommandTimeout').value = data.SavedCommandTimeout; + if (data.SavedCommandTimeout != null) document.getElementById('pgCommandTimeout').value = data.SavedCommandTimeout; if (data.SqliteDefaultPath) document.getElementById('sqlitePath').value = data.SqliteDefaultPath; if (data.SavedBackupDirectory) { document.getElementById('configBackupDirectory').value = data.SavedBackupDirectory; @@ -821,11 +1176,8 @@

Estado de la base de datos

if (typeof data.SavedBackupCompression === 'boolean') { document.getElementById('configBackupCompress').checked = data.SavedBackupCompression; } - if (typeof data.SavedPgDumpPath === 'string') { - document.getElementById('configPgDumpPath').value = data.SavedPgDumpPath; - } - if (typeof data.SavedPgRestorePath === 'string') { - document.getElementById('configPgRestorePath').value = data.SavedPgRestorePath; + if (typeof data.SavedPgBinPath === 'string') { + document.getElementById('configPgBinPath').value = data.SavedPgBinPath; } if (data.MigrationState === 'InProgress') { @@ -862,19 +1214,17 @@

Estado de la base de datos

document.getElementById('btnSaveConfig').addEventListener('click', function() { var connStr = buildConnStr(); - var timeout = parseInt(document.getElementById('pgCommandTimeout').value, 10) || 60; + var timeout = parseInt(document.getElementById('pgCommandTimeout').value, 10); if (isNaN(timeout) || timeout < 0) timeout = 60; var backupDirectory = document.getElementById('configBackupDirectory').value.trim(); var backupCompression = !!document.getElementById('configBackupCompress').checked; - var pgDumpPath = document.getElementById('configPgDumpPath').value.trim(); - var pgRestorePath = document.getElementById('configPgRestorePath').value.trim(); + var pgBinPath = document.getElementById('configPgBinPath').value.trim(); if (!document.getElementById('pgHost').value.trim()) { showBanner(t('msg_enter_host', 'Enter the PostgreSQL host.'), 'error'); return; } apiPost('/SaveConfig', { connectionString: connStr, commandTimeout: timeout, backupDirectory: backupDirectory, backupCompression: backupCompression, - pgDumpPath: pgDumpPath, - pgRestorePath: pgRestorePath + pgBinPath: pgBinPath }) .then(function(r) { return r.json(); }) .then(function(data) { @@ -891,7 +1241,7 @@

Estado de la base de datos

var resultEl = document.getElementById('activateResult'); if (!document.getElementById('pgHost').value.trim()) { showBanner(t('msg_enter_connection_first', 'Complete the connection details in the Configuration tab.'), 'error'); return; } resultEl.textContent = t('msg_activating', 'Activating...'); - apiPost('/Activate', { connectionString: connStr, commandTimeout: timeout || 60 }) + apiPost('/Activate', { connectionString: connStr, commandTimeout: (isNaN(timeout) || timeout < 0) ? 60 : timeout }) .then(function(r) { return r.json(); }) .then(function(data) { resultEl.innerHTML = '✓ ' + escapeHtml(data.Status || data.Error); @@ -900,27 +1250,60 @@

Estado de la base de datos

} function askActivate(timeout) { - showConfirm(t('msg_activate_restart_confirm', 'This will write database.xml and restart Jellyfin. Continue?'), function() { - doActivate(timeout); - }); + showConfirm( + t('msg_activate_restart_confirm', 'Esto escribirá database.xml y reiniciará Jellyfin. ¿Continuar?'), + function() { doActivate(timeout); }, + { icon: '\uD83D\uDE80', title: t('heading_activate', 'Activar PostgreSQL'), confirmLabel: t('button_activate', 'Activar y reiniciar'), confirmColor: 'safe' } + ); } function askDeactivate() { - showConfirm(t('msg_deactivate_confirm', 'This will remove database.xml and switch back to SQLite. Continue?'), function() { - apiPost('/Deactivate', {}).then(function(r) { return r.json(); }) - .then(function(data) { showBanner(data.Status || data.Error, 'info'); }) - .catch(function(err) { showBanner('Error: ' + err, 'error'); }); - }); + showConfirm( + t('msg_deactivate_confirm', 'Esto eliminará database.xml y revertirá a SQLite. ¿Continuar?'), + function() { + apiPost('/Deactivate', {}).then(function(r) { return r.json(); }) + .then(function(data) { showBanner(data.Status || data.Error, 'info'); }) + .catch(function(err) { showBanner('Error: ' + err, 'error'); }); + }, + { icon: '\u21A9\uFE0F', title: t('button_deactivate', 'Revertir a SQLite'), confirmLabel: t('button_deactivate', 'Revertir y reiniciar'), confirmColor: 'warning' } + ); } document.getElementById('btnActivate').addEventListener('click', function() { - askActivate(parseInt(document.getElementById('pgCommandTimeout').value, 10) || 60); + var t0 = parseInt(document.getElementById('pgCommandTimeout').value, 10); askActivate(isNaN(t0) || t0 < 0 ? 60 : t0); }); document.getElementById('btnActivateAfterMigration').addEventListener('click', function() { - askActivate(parseInt(document.getElementById('pgCommandTimeout').value, 10) || 60); + var t1 = parseInt(document.getElementById('pgCommandTimeout').value, 10); askActivate(isNaN(t1) || t1 < 0 ? 60 : t1); }); document.getElementById('btnDeactivate').addEventListener('click', askDeactivate); + document.getElementById('btnTruncatePg').addEventListener('click', function() { + var r = document.getElementById('truncateResult'); + showConfirm( + t('msg_truncate_confirm', 'Esto eliminará TODOS los datos de Jellyfin en PostgreSQL.\n\nEl schema se conserva. Operación irreversible sin backup previo.\n\n¿Confirmar limpieza?'), + function() { + r.style.color = '#f3c969'; + r.textContent = t('msg_truncate_running', 'Cleaning...'); + apiPost('/TruncateAllTables', {}) + .then(function(resp) { return resp.json(); }) + .then(function(d) { + if (d.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C Error: ' + d.Error; + } else { + r.style.color = '#81c784'; + r.textContent = '\u2713 ' + d.TablesAffected + t('msg_truncate_done', ' tables cleaned.') + ' ' + (d.TotalRowsRemoved || 0).toLocaleString() + t('msg_truncate_rows', ' rows deleted.'); + } + }) + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = '\u274C Error: ' + e; + }); + }, + { icon: '\uD83D\uDDD1\uFE0F', title: t('truncate_heading', 'Limpiar PostgreSQL'), confirmLabel: t('button_truncate_pg', 'Limpiar (TRUNCATE ALL)'), confirmColor: 'danger' } + ); + }); + document.getElementById('btnStartMigration').addEventListener('click', function() { var sqlitePath = document.getElementById('sqlitePath').value.trim(); var connStr = buildConnStr(); @@ -972,8 +1355,13 @@

Estado de la base de datos

} function loadStats() { - apiGet('/Stats?schema=public').then(function(r) { return r.json(); }).then(function(data) { - if (data.Error) { document.getElementById('maintenanceResult').textContent = data.Error; return; } + var r = document.getElementById('maintenanceResult'); + return apiGet('/Stats?schema=public').then(function(resp) { return resp.json(); }).then(function(data) { + if (data.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + data.Error; + return; + } document.getElementById('statsSection').style.display = ''; document.getElementById('dbOverview').innerHTML = escapeHtml(t('msg_db_size_prefix', 'DB size: ')) + '' + escapeHtml(data.DatabaseSize) + '  |  ' + escapeHtml(t('msg_active_connections_prefix', 'Active connections: ')) + '' + data.ActiveConnections + ''; @@ -990,67 +1378,214 @@

Estado de la base de datos

'' + escapeHtml(row.IndexSize) + ''; tbody.appendChild(tr); }); - }).catch(function(err) { document.getElementById('maintenanceResult').textContent = 'Error: ' + err; }); + r.style.color = '#81c784'; + r.textContent = '\u2713 Estad\u00EDsticas actualizadas (' + (data.Tables || []).length + ' tablas)'; + }).catch(function(err) { + r.style.color = '#ff7070'; + r.textContent = '\u274C Error: ' + err; + }); } - document.getElementById('btnRefreshStats').addEventListener('click', loadStats); + document.getElementById('btnRefreshStats').addEventListener('click', function() { + var r = document.getElementById('maintenanceResult'); + r.style.color = '#90caf9'; + r.textContent = '\uD83D\uDCCA Actualizando estad\u00EDsticas...'; + loadStats(); + }); + document.getElementById('btnVacuum').addEventListener('click', function() { var r = document.getElementById('maintenanceResult'); - r.textContent = t('msg_vacuum_running', 'Running VACUUM ANALYZE in background...'); - apiPost('/Vacuum', {}).then(function(resp) { return resp.json(); }) - .then(function(d) { r.textContent = d.Status || d.Error; }) - .catch(function(e) { r.textContent = 'Error: ' + e; }); + var btn = document.getElementById('btnVacuum'); + r.style.color = '#f3c969'; + r.textContent = '\u23F3 VACUUM ANALYZE ejecut\u00E1ndose... (puede tardar varios segundos)'; + btn.disabled = true; + apiPost('/Vacuum', {}) + .then(function(resp) { return resp.json(); }) + .then(function(d) { + if (d.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C Error: ' + d.Error; + } else { + r.style.color = '#81c784'; + r.textContent = '\u2713 ' + (d.Status || d.status); + } + btn.disabled = false; + }) + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = 'Error: ' + e; + btn.disabled = false; + }); }); + document.getElementById('btnReindex').addEventListener('click', function() { var r = document.getElementById('maintenanceResult'); - showConfirm(t('msg_reindex_confirm', 'REINDEX may take several minutes. Continue?'), function() { - r.textContent = t('msg_reindex_running', 'Running REINDEX in background...'); - apiPost('/Reindex', {}).then(function(resp) { return resp.json(); }) - .then(function(d) { r.textContent = d.Status || d.Error; }) - .catch(function(e) { r.textContent = 'Error: ' + e; }); - }); + var btn = document.getElementById('btnReindex'); + showConfirm(t('msg_reindex_confirm', 'REINDEX puede tardar varios minutos. ¿Continuar?'), function() { + r.style.color = '#f3c969'; + r.textContent = '\u23F3 REINDEX DATABASE ejecut\u00E1ndose... (puede tardar varios minutos)'; + btn.disabled = true; + apiPost('/Reindex', {}) + .then(function(resp) { return resp.json(); }) + .then(function(d) { + if (d.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C Error: ' + d.Error; + } else { + r.style.color = '#81c784'; + r.textContent = '\u2713 ' + (d.Status || d.status); + } + btn.disabled = false; + }) + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = 'Error: ' + e; + btn.disabled = false; + }); + }, + { icon: '\uD83D\uDD0D', title: 'REINDEX DATABASE', confirmLabel: 'Iniciar REINDEX', confirmColor: 'confirm' } + ); }); + // ── Backup list ────────────────────────────────────────────────── + var _backupFiles = []; + + function loadBackupList() { + var sel = document.getElementById('backupSelector'); + var meta = document.getElementById('backupSelectorMeta'); + sel.innerHTML = ''; + meta.textContent = ''; + + apiGet('/ListBackups') + .then(function(resp) { return resp.json(); }) + .then(function(data) { + _backupFiles = data.Files || data.files || []; + sel.innerHTML = ''; + + if (_backupFiles.length === 0) { + sel.innerHTML = ''; + meta.textContent = (data.Directory || data.directory || ''); + return; + } + + _backupFiles.forEach(function(f, i) { + var opt = document.createElement('option'); + var name = f.Name || f.name || ''; + var size = f.SizeFormatted || f.sizeFormatted || ''; + var date = f.CreatedAtUtc || f.createdAtUtc || ''; + var dateStr = date ? new Date(date).toLocaleString() : ''; + opt.value = i; + opt.textContent = name + ' \u2014 ' + size + (dateStr ? ' \u2014 ' + dateStr : ''); + sel.appendChild(opt); + }); + + // Show first item meta + updateBackupMeta(); + meta.style.color = '#78909c'; + meta.textContent = (data.Directory || data.directory || ''); + }) + .catch(function(e) { + sel.innerHTML = ''; + meta.style.color = '#ff7070'; + meta.textContent = 'Error: ' + e; + }); + } + + function updateBackupMeta() { + var sel = document.getElementById('backupSelector'); + var meta = document.getElementById('backupSelectorMeta'); + var idx = parseInt(sel.value, 10); + if (isNaN(idx) || !_backupFiles[idx]) { return; } + var f = _backupFiles[idx]; + var size = f.SizeFormatted || f.sizeFormatted || ''; + var date = f.CreatedAtUtc || f.createdAtUtc || ''; + var dateStr = date ? new Date(date).toLocaleString() : ''; + var path = f.Path || f.path || ''; + meta.style.color = '#78909c'; + meta.textContent = t('backup_meta_size', 'Size: ') + size + (dateStr ? t('backup_meta_date', ' | Date: ') + dateStr : '') + '\n' + path; + } + + function getSelectedBackupPath() { + var sel = document.getElementById('backupSelector'); + var manual = document.getElementById('restoreBackupPath').value.trim(); + if (manual) { return manual; } + var idx = parseInt(sel.value, 10); + if (!isNaN(idx) && _backupFiles[idx]) { + return _backupFiles[idx].Path || _backupFiles[idx].path || ''; + } + return ''; + } + + document.getElementById('backupSelector').addEventListener('change', updateBackupMeta); + document.getElementById('btnRefreshBackups').addEventListener('click', loadBackupList); + document.getElementById('btnCreateBackup').addEventListener('click', function() { var r = document.getElementById('maintenanceResult'); + var btn = document.getElementById('btnCreateBackup'); + r.style.color = '#f3c969'; r.textContent = t('msg_backup_running', 'Creating backup...'); - apiPost('/Backup', {}).then(function(resp) { return resp.json(); }) + btn.disabled = true; + apiPost('/Backup', {}) + .then(function(resp) { return resp.json(); }) .then(function(d) { if (d.Success) { - r.textContent = t('msg_backup_done_prefix', 'Backup created: ') + (d.BackupPath || ''); + r.style.color = '#81c784'; + var size = d.SizeFormatted || d.sizeFormatted || ''; + r.textContent = '\u2713 ' + t('msg_backup_done_size', 'Backup created: ') + (d.BackupPath || '') + (size ? ' (' + size + ')' : ''); + loadBackupList(); // refresh selector automatically } else { - r.textContent = t('msg_backup_error_prefix', 'Backup failed: ') + (d.Error || t('msg_unknown_error', 'unknown error')); + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_backup_error_prefix', 'Backup failed: ') + (d.Error || t('msg_unknown_error', 'unknown error')); } + btn.disabled = false; }) - .catch(function(e) { r.textContent = t('msg_backup_error_prefix', 'Backup failed: ') + e; }); + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_backup_error_prefix', 'Backup failed: ') + e; + btn.disabled = false; + }); }); document.getElementById('btnRestoreBackup').addEventListener('click', function() { var r = document.getElementById('maintenanceResult'); - var backupPath = document.getElementById('restoreBackupPath').value.trim(); - var pgRestorePath = document.getElementById('configPgRestorePath').value.trim(); + var backupPath = getSelectedBackupPath(); if (!backupPath) { - r.textContent = t('msg_restore_path_required', 'Enter the backup path to restore.'); + r.style.color = '#f3c969'; + r.textContent = t('msg_restore_path_required', 'Select or enter a backup path to restore.'); return; } - showConfirm(t('msg_restore_confirm', 'This will restore data into PostgreSQL and may overwrite current data. Continue?'), function() { + showConfirm( + t('msg_restore_confirm', 'Esto restaurará datos en PostgreSQL y puede sobrescribir el contenido actual. ¿Continuar?'), + function() { + var btn = document.getElementById('btnRestoreBackup'); + r.style.color = '#f3c969'; r.textContent = t('msg_restore_running', 'Restoring backup...'); + btn.disabled = true; apiPost('/RestoreBackup', { backupPath: backupPath, - pgRestorePath: pgRestorePath, + pgBinPath: document.getElementById('configPgBinPath').value.trim(), replaceExistingObjects: true }).then(function(resp) { return resp.json(); }) .then(function(d) { if (d.Success) { - r.textContent = t('msg_restore_done_prefix', 'Restore completed from: ') + (d.RestoredFrom || backupPath); + r.style.color = '#81c784'; + r.textContent = '\u2713 ' + t('msg_restore_done_size', 'Restore completed from: ') + (d.RestoredFrom || backupPath); } else { - r.textContent = t('msg_restore_error_prefix', 'Restore failed: ') + (d.Error || t('msg_unknown_error', 'unknown error')); + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_restore_error_prefix', 'Restore failed: ') + (d.Error || t('msg_unknown_error', 'unknown error')); } + btn.disabled = false; }) - .catch(function(e) { r.textContent = t('msg_restore_error_prefix', 'Restore failed: ') + e; }); - }); + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_restore_error_prefix', 'Restore failed: ') + e; + btn.disabled = false; + }); + }, + { icon: '\u267B\uFE0F', title: t('heading_backup', 'Restaurar backup'), confirmLabel: t('button_restore_backup', 'Restaurar'), confirmColor: 'warning' } + ); }); function escapeHtml(str) { @@ -1058,6 +1593,340 @@

Estado de la base de datos

return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + // ── Performance Optimization ────────────────────────────────────── + function loadOptimizationStatus() { + apiGet('/OptimizationStatus') + .then(function(r) { return r.json(); }) + .then(function(d) { + var section = document.getElementById('optStatusSection'); + var badge = document.getElementById('optTrgmBadge'); + var list = document.getElementById('optIndexList'); + section.style.display = ''; + // cover both camelCase (System.Text.Json) and PascalCase serializers + var trgmOk = !!(d.trgmAvailable != null ? d.trgmAvailable : d.TrgmAvailable); + if (trgmOk) { + badge.style.background = '#1b3a1b'; + badge.style.color = '#81c784'; + badge.style.border = '1px solid #388e3c'; + badge.textContent = '\u2713 pg_trgm disponible'; + } else { + badge.style.background = '#3a1b1b'; + badge.style.color = '#e57373'; + badge.style.border = '1px solid #c62828'; + badge.textContent = '\u2717 pg_trgm no disponible'; + } + var html = ''; + var indexes = d.ginIndexes != null ? d.ginIndexes : d.GinIndexes; + if (Array.isArray(indexes)) { + indexes.forEach(function(idx) { + // GinIndexInfo.IsValid — handle both casings + var valid = idx.isValid != null ? idx.isValid : idx.IsValid; + var name = idx.indexName != null ? idx.indexName : idx.IndexName; + html += '' + + (valid ? '\u2713' : '\u2717') + ' ' + + escapeHtml(name) + '
'; + }); + } + list.innerHTML = html; + }) + .catch(function() {}); + } + + document.getElementById('btnApplyOptimizations').addEventListener('click', function() { + var r = document.getElementById('optResult'); + r.style.color = '#a0c4ff'; + r.textContent = t('msg_opt_running', 'Applying optimizations in background...'); + document.getElementById('btnApplyOptimizations').disabled = true; + apiPost('/ApplyOptimizations', {}) + .then(function(resp) { return resp.json(); }) + .then(function(d) { + r.style.color = '#81c784'; + r.textContent = t('msg_opt_done', 'Optimization job started. GIN indexes may take a few minutes on large databases.'); + // Poll until pg_trgm is confirmed available (max ~60 s) + var attempts = 0; + function pollUntilReady() { + attempts++; + loadOptimizationStatus(); + if (attempts < 12) { setTimeout(pollUntilReady, 5000); } + } + setTimeout(pollUntilReady, 3000); + }) + .catch(function(e) { + r.style.color = '#ef9a9a'; + r.textContent = 'Error: ' + e; + }) + .finally(function() { + document.getElementById('btnApplyOptimizations').disabled = false; + }); + }); + + // ── Export to SQLite ────────────────────────────────────────────── + var exportPollTimer = null; + + function stopExportPoll() { + if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null; } + } + + function updateExportProgress(raw) { + // Normalize PascalCase → camelCase (ASP.NET default serialization) + var d = { + isRunning: raw.IsRunning !== undefined ? raw.IsRunning : raw.isRunning, + isCompleted: raw.IsCompleted !== undefined ? raw.IsCompleted : raw.isCompleted, + hasError: raw.HasError !== undefined ? raw.HasError : raw.hasError, + percentComplete: raw.PercentComplete !== undefined ? raw.PercentComplete : raw.percentComplete, + currentTable: raw.CurrentTable !== undefined ? raw.CurrentTable : raw.currentTable, + exportedRows: raw.ExportedRows !== undefined ? raw.ExportedRows : raw.exportedRows, + totalRows: raw.TotalRows !== undefined ? raw.TotalRows : raw.totalRows, + logLines: raw.LogLines !== undefined ? raw.LogLines : raw.logLines, + targetSqlitePath: raw.TargetSqlitePath !== undefined ? raw.TargetSqlitePath : raw.targetSqlitePath, + errorMessage: raw.ErrorMessage !== undefined ? raw.ErrorMessage : raw.errorMessage + }; + + var bar = document.getElementById('exportProgressBar'); + var label = document.getElementById('exportProgressLabel'); + var log = document.getElementById('exportLogBox'); + var res = document.getElementById('exportResult'); + + var pct = d.percentComplete || 0; + bar.style.width = pct + '%'; + bar.textContent = pct + '%'; + + var labelParts = []; + if (d.currentTable) labelParts.push(t('msg_export_table_prefix', 'Current table: ') + d.currentTable); + if (d.totalRows > 0) labelParts.push(t('msg_export_rows_prefix', 'Exported rows: ') + (d.exportedRows || 0).toLocaleString() + ' / ' + (d.totalRows || 0).toLocaleString()); + label.textContent = labelParts.join(' '); + + if (d.logLines && d.logLines.length) { + log.textContent = d.logLines.join('\n'); + log.scrollTop = log.scrollHeight; + } + + if (!d.isRunning && d.isCompleted && !d.hasError) { + stopExportPoll(); + res.style.color = '#6bc47f'; + res.textContent = t('msg_export_done_prefix', 'Export completed: ') + (d.targetSqlitePath || ''); + document.getElementById('btnStartExport').disabled = false; + } + + if (!d.isRunning && d.hasError) { + stopExportPoll(); + res.style.color = '#ff7070'; + res.textContent = 'Error: ' + (d.errorMessage || ''); + document.getElementById('btnStartExport').disabled = false; + } + } + + function startExportPoll() { + stopExportPoll(); + exportPollTimer = setInterval(function() { + apiGet('/ExportToSqliteProgress') + .then(function(r) { return r.json(); }) + .then(function(d) { updateExportProgress(d); }) + .catch(function() {}); + }, 1500); + } + + document.getElementById('btnStartExport').addEventListener('click', function() { + var targetPath = document.getElementById('exportTargetPath').value.trim(); + var res = document.getElementById('exportResult'); + res.textContent = ''; + + function doExport(overwriteMode) { + + var bar = document.getElementById('exportProgressBar'); + bar.style.width = '0%'; bar.textContent = '0%'; + document.getElementById('exportProgressLabel').textContent = t('msg_export_running', 'Export in progress...'); + document.getElementById('exportLogBox').textContent = ''; + document.getElementById('exportProgressSection').style.display = ''; + document.getElementById('btnStartExport').disabled = true; + + apiPost('/StartExportToSqlite', { targetSqlitePath: targetPath || null, overwriteMode: overwriteMode }) + .then(function(resp) { + if (resp.status === 409) { + res.style.color = '#f3c969'; + res.textContent = t('msg_export_conflict', 'An export is already in progress.'); + document.getElementById('btnStartExport').disabled = false; + startExportPoll(); + return null; + } + if (resp.status === 400) { + return resp.json().then(function(d) { + res.style.color = '#ff7070'; + res.textContent = 'Error: ' + (d.Error || d.error || 'Bad request'); + document.getElementById('btnStartExport').disabled = false; + return null; + }); + } + return resp.json(); + }) + .then(function(d) { + if (d && d.targetSqlitePath) { + document.getElementById('exportTargetPath').value = d.targetSqlitePath; + } + if (d && (d.Status || d.status || d.TargetSqlitePath || d.targetSqlitePath)) startExportPoll(); + }) + .catch(function(e) { + res.style.color = '#ff7070'; + res.textContent = t('msg_export_start_error', 'Error starting export: ') + e; + document.getElementById('btnStartExport').disabled = false; + }); + } + + // Step 1: check if a previous export is running + apiGet('/ExportToSqliteProgress') + .then(function(r) { return r.json(); }) + .then(function(prog) { + var isRunning = prog.IsRunning !== undefined ? prog.IsRunning : prog.isRunning; + if (isRunning) { + res.style.color = '#f3c969'; + res.textContent = t('msg_export_conflict', 'An export is already in progress.'); + startExportPoll(); + return; + } + + // Step 2: ask the server if the target file already exists + var checkUrl = '/CheckExportTarget' + (targetPath ? '?path=' + encodeURIComponent(targetPath) : ''); + apiGet(checkUrl) + .then(function(r2) { return r2.json(); }) + .then(function(check) { + var fileExists = check.Exists !== undefined ? check.Exists : check.exists; + var resolvedPath = check.Path || check.path || targetPath || 'auto'; + + if (!fileExists) { + // File does not exist — create schema and export directly + doExport('use'); + return; + } + + // File exists — 3-option dialog via native + document.getElementById('pgDlgIcon').textContent = '\uD83D\uDCC4'; + document.getElementById('pgDlgTitleText').textContent = 'Exportar PostgreSQL \u2192 SQLite'; + _dlgText.textContent = 'El archivo de destino ya existe:\n' + resolvedPath + '\n\n\u00BFQu\u00E9 deseas hacer?'; + _dlgAct.innerHTML = ''; + _dlgAct.appendChild(makeDlgBtn('\u274C Cancelar', 'cancel', 'cancel')); + _dlgAct.appendChild(makeDlgBtn('\u26A0\uFE0F Sobreescribir sin backup', 'danger', 'overwrite')); + _dlgAct.appendChild(makeDlgBtn('\uD83D\uDCBE Renombrar a .bkp y exportar','confirm', 'backup')); + _dlg.onclose = function() { + var v = _dlg.returnValue; + if (v === 'backup') { doExport('backup'); } + else if (v === 'overwrite') { doExport('overwrite'); } + else { document.getElementById('btnStartExport').disabled = false; } + }; + _dlg.showModal(); + }) + .catch(function() { doExport('use'); }); + }) + .catch(function() { doExport('use'); }); + }); + + // ── Health Check ────────────────────────────────────────────────── + var _healthFindings = []; + + function healthSevInfo(sev) { + sev = String(sev || 'Ok').toLowerCase(); + if (sev === 'warn') return { icon: '\uD83D\uDFE1', label: t('health_sev_warn', 'Warnings detected'), color: '#f3c969' }; + if (sev === 'error') return { icon: '\uD83D\uDD34', label: t('health_sev_error', 'Critical issues detected'), color: '#ef9a9a' }; + return { icon: '\uD83D\uDFE2', label: t('health_sev_ok', 'All checks passed'), color: '#81c784' }; + } + + function renderHealthFindings(d) { + var overall = d.OverallSeverity !== undefined ? d.OverallSeverity : d.overallSeverity; + var findings = d.Findings !== undefined ? d.Findings : (d.findings || []); + var duration = d.DurationMs !== undefined ? d.DurationMs : d.durationMs; + _healthFindings = findings; + + var info = healthSevInfo(overall); + var r = document.getElementById('healthResult'); + r.innerHTML = '' + info.icon + ' ' + escapeHtml(info.label) + '' + + '  |  ' + escapeHtml(String(overall || '')) + + '  |  ' + findings.length + t('msg_health_findings_suffix', ' finding(s)') + + (duration != null ? '  |  ' + duration + ' ms' : ''); + + var box = document.getElementById('healthFindings'); + box.style.display = findings.length ? '' : 'none'; + var html = ''; + findings.forEach(function(f) { + var fsev = f.Severity !== undefined ? f.Severity : f.severity; + var finfo = healthSevInfo(fsev); + var check = f.Check !== undefined ? f.Check : f.check; + var msg = f.Message !== undefined ? f.Message : f.message; + var det = f.Detail !== undefined ? f.Detail : f.detail; + var repairable = (f.Repairable !== undefined ? f.Repairable : f.repairable) === true; + html += '
' + + '' + finfo.icon + '' + + '
' + + '' + escapeHtml(String(check || '')) + '' + + (repairable ? ' ' + t('msg_health_repairable_tag', '[auto-repairable]') + '' : '') + + '
' + escapeHtml(String(msg || '')) + '
' + + (det ? '
' + escapeHtml(String(det)) + '
' : '') + + '
'; + }); + box.innerHTML = html; + + var repairable = findings.some(function(f) { + return (f.Repairable !== undefined ? f.Repairable : f.repairable) === true; + }); + document.getElementById('btnHealthRepair').style.display = repairable ? '' : 'none'; + } + + function loadHealthCheck() { + var r = document.getElementById('healthResult'); + r.style.color = '#a0c4ff'; + r.textContent = '\uD83D\uDD2C ' + t('msg_health_running', 'Running health check...'); + apiGet('/HealthCheck') + .then(function(resp) { return resp.json(); }) + .then(function(d) { + if (d.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + d.Error; + return; + } + renderHealthFindings(d); + r.style.color = ''; + }) + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_network_error', 'Network error: ') + e; + }); + } + + function runHealthRepair() { + var r = document.getElementById('healthResult'); + var btn = document.getElementById('btnHealthRepair'); + r.style.color = '#f3c969'; + r.textContent = '\uD83D\uDD27 ' + t('msg_health_repair_running', 'Repairing database...'); + btn.disabled = true; + apiPost('/HealthCheck/Repair', {}) + .then(function(resp) { return resp.json(); }) + .then(function(d) { + if (d.Error) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + d.Error; + } else { + var items = d.Items !== undefined ? d.Items : (d.items || []); + var ok = items.filter(function(i) { + return (i.Success !== undefined ? i.Success : i.success) === true; + }).length; + var failed = items.length - ok; + r.style.color = '#81c784'; + r.textContent = '\u2713 ' + t('msg_health_repair_done_prefix', 'Auto-repair finished: ') + items.length + + t('msg_health_repair_actions_suffix', ' actions (') + ok + + t('msg_health_repair_ok', ' ok, ') + failed + + t('msg_health_repair_failed', ' failed).'); + loadHealthCheck(); // refresh status after repair + } + btn.disabled = false; + }) + .catch(function(e) { + r.style.color = '#ff7070'; + r.textContent = '\u274C ' + t('msg_network_error', 'Network error: ') + e; + btn.disabled = false; + }); + } + + document.getElementById('btnHealthCheck').addEventListener('click', loadHealthCheck); + document.getElementById('btnHealthRepair').addEventListener('click', runHealthRepair); + applyLocalization(); document.getElementById('currentStatusBox').textContent = t('msg_loading', 'Loading...'); document.querySelector('.pg-tab-btn[data-tab="config"]').click(); diff --git a/README.en.md b/README.en.md index b96ae60..9be76c8 100644 --- a/README.en.md +++ b/README.en.md @@ -1,131 +1,312 @@ -# PostgreSQL for Jellyfin +
![PostgreSQL plugin logo](Jellyfin.Database.Providers.Postgres/Resources/logo.png) -Use PostgreSQL as Jellyfin's database backend without modifying Jellyfin core. +🌐  [**Español**](README.md)  ·  **English** -[![Jellyfin 10.11.x](https://img.shields.io/badge/Jellyfin-10.11.x-blue?style=flat-square)](https://jellyfin.org) -[![Release](https://img.shields.io/github/v/release/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest) +
+ +# 🐘 PostgreSQL Database Provider + +**Jellyfin plugin** that replaces SQLite with PostgreSQL as the database engine, +without modifying Jellyfin core. Bidirectional migration, near-instant search, +automatic health check, scheduled maintenance and optional JellyTrend integration. + +
+ +[![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) +[![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) -[![Reddit](https://img.shields.io/badge/Reddit-r%2Fjellyfin-FF4500?style=flat-square&logo=reddit&logoColor=white)](https://www.reddit.com/r/jellyfin) +[![License](https://img.shields.io/github/license/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=555)](LICENSE) -Language: [Español](README.md) | [English](README.en.md) +
-## What it solves +--- -- Better behavior with large libraries and concurrent use. -- Backup and restore from plugin UI (pg_dump/psql). -- Controlled switch between PostgreSQL and SQLite. +## ✨ Features -## Compatibility +- 🐘 **PostgreSQL as native backend** — EF Core + Npgsql, no Jellyfin core changes required. +- 🔍 **Near-instant search** (< 15 ms typical) with GIN trigram index (`pg_trgm`); automatic fallback to `ILIKE`. +- 🔄 **Bidirectional migration** — SQLite → PostgreSQL and PostgreSQL → SQLite without external tools. +- 🩺 **Automatic health check** at startup: diagnoses bloat, invalid indexes, drifted sequences and slow queries. +- 🛡️ **Error prevention** — EF Core interceptors for upserts, DB error logging and `DateTime.Kind` normalization. +- 🔧 **Automatic optimization** — GIN indexes CONCURRENTLY, autovacuum tuning on critical tables. +- 💾 **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. +- 🔁 **One-click rollback to SQLite** from the configuration tab. + +## ⚙️ Compatibility | Component | Version | -| --- | --- | -| Jellyfin | 10.11.10 | -| PostgreSQL | 13+ (recommended 16/17) | +|---|---| +| Jellyfin | **10.11.10** | +| PostgreSQL | **13 +** (recommended 16 / 17) | +| .NET | 9.0 | + +> ℹ️ The plugin uses `IJellyfinDatabaseProvider` — the same interface as SQLite. No Jellyfin source modifications required. -## Install +--- -### Option A: plugin repository (recommended) +## 🚀 Installation -1. In Jellyfin: Admin -> Plugins -> Plugin Repositories. -2. Add this URL: +### Option A — Plugin repository (recommended) -```text -https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json -``` +1. In Jellyfin go to **Dashboard → Advanced → Plugin repositories**. +2. Click **Add repository** and use this URL: -3. Save, refresh catalog, install PostgreSQL Database Provider. -4. Restart Jellyfin. + ``` + https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json + ``` -### Option B: manual ZIP install +3. Save, go to **Catalog**, search **PostgreSQL Database Provider** and **Install**. +4. Restart Jellyfin when prompted. -1. Download latest release ZIP. -2. Extract to Jellyfin plugin directory. +### Option B — Manual ZIP + +1. Download the latest [**Release**](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest) ZIP. +2. Extract into your Jellyfin plugins directory. 3. Restart Jellyfin. -## How to migrate from SQLite to PostgreSQL +> 💡 Common plugin directory locations: +> - **Linux / Docker:** `/config/plugins/` +> - **Windows:** `%LOCALAPPDATA%\jellyfin\plugins\` + +--- + +## 🖥️ Plugin tabs + +### ⚙️ Configuration + +Manage connection, connection pool and external tool paths. + +![Configuration Tab](Screenshots/Tab-Configurations.png) + +| 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 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. + +--- + +### 🩺 Health Check + +Automatic database diagnostics. Runs **10 seconds after startup** and can be triggered manually from the UI. + +![Health Check Tab](Screenshots/Tab-Health.png) + +| Check | Description | +|---|---| +| **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`) | + +Results use a traffic-light system: `Info` / `Warn` / `Error`. Repairable issues can be fixed in one click from the same card. + +--- + +### 🔄 Migration + +Two operations with live progress: + +![Migration Tab](Screenshots/Tab-Migrations.png) + +#### SQLite → PostgreSQL + +Copies `jellyfin.db` to PostgreSQL table by table. + +1. `jellyfin.db` path is auto-detected (`{DataPath}/jellyfin.db`). +2. Adjust **batch size** (default 1000). +3. Enable **Truncate tables before insert** when repeating migration over existing data. +4. Click **Start migration** and follow progress. +5. At 100 %, activate PostgreSQL from the Configuration tab. + +> ⚠️ The `--truncate` option runs `TRUNCATE + RESTART IDENTITY + CASCADE` on destination. Use on retries to avoid duplicates; destructive on existing data. + +#### PostgreSQL → SQLite -Installing the plugin does not migrate data by itself. +Exports the entire database to a native `.db` file without external tools. -1. Create an empty PostgreSQL database (for example `jellyfin`). -2. Run SQLite -> PostgreSQL migration using Jellyfin.SqliteToPostgres.Migrator. -3. Validate key row counts (for example `UserData`, `Users`, `TypedBaseItems`). -4. In plugin settings, set your PostgreSQL connection string. -5. Activate PostgreSQL from the plugin UI (writes `database.xml` and restarts Jellyfin). +- If `.db` doesn't exist → created with schema derived from PostgreSQL. +- If `.db` already exists → table structure is preserved; only data is replaced (`DELETE` + `INSERT`). +- Written in 10,000-row transactions with `PRAGMA journal_mode=WAL`. -Example connection string: +**Use cases:** revert to SQLite, portable backup, local inspection. -```text -Host=127.0.0.1;Port=5432;Database=jellyfin;Username=jellyfin;Password=CHANGE_ME;Pooling=true;Maximum Pool Size=200 -``` +--- -## How to activate PostgreSQL +### 🛠️ Maintenance -From plugin UI: +Maintenance operations, backups and database statistics. -1. Set connection string and timeout. -2. Save settings. -3. Click Activate PostgreSQL. -4. Confirm restart. +![Maintenance Tab](Screenshots/Tab-Maintenance.png) -Expected result: +| Action | Description | +|---|---| +| **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 | -- `database.xml` is configured as `PLUGIN_PROVIDER`. -- Jellyfin starts with `Jellyfin.Database.Providers.Postgres.dll`. +--- -## How to rollback to SQLite +## 🔍 Near-instant search -From plugin UI: +When GIN indexes are active, the plugin exposes its own search endpoint that bypasses EF Core: -1. Click Deactivate PostgreSQL / Revert to SQLite. -2. Plugin removes `database.xml`. +- Typical latency **< 15 ms** on medium and large libraries (depends on hardware and client). +- Searches by item name, path and type. +- **Automatic fallback** to `ILIKE` if GIN indexes don't exist yet. +- Activated from **Maintenance → Apply optimizations**. + +--- + +## 🔁 Activate / Deactivate PostgreSQL + +### Activate PostgreSQL + +1. Configure and test the connection in the **Configuration** tab. +2. Migrate data (**Migration → SQLite → PostgreSQL**). +3. Click **Activate PostgreSQL** and confirm the restart. + +Result: `database.xml` is written in `PLUGIN_PROVIDER` mode pointing to this plugin. + +### Roll back to SQLite + +1. In **Configuration** click **Deactivate / Revert to SQLite**. +2. The plugin removes `database.xml`. 3. Restart Jellyfin. -When rollback makes sense: +When to roll back: PostgreSQL connectivity failure, urgent maintenance, incomplete migration. + +--- + +## 💾 Backups + +### Configure + +In **Configuration**: +- **PgBin path** — path to `pg_dump`/`psql`/`pg_restore`. Leave empty to use system PATH. +- **Backup directory** — destination folder. +- **Backup compression** — enable ZIP of the `.sql`. + +### Manual + +In **Maintenance**: **Create backup now** / **Restore backup** (accepts `.sql` or `.zip`). + +--- + +## 📅 Scheduled tasks -- PostgreSQL connectivity problem. -- Maintenance window or incomplete migration. -- You need fast service recovery while fixing PG. +| Task | Default | Description | +|---|---|---| +| **PostgreSQL Backup** | Daily 02:00 | Backup with `pg_dump` | +| **PostgreSQL VACUUM ANALYZE** | Sunday 03:00 | Free space and update statistics | +| **PostgreSQL REINDEX DATABASE** | Sunday 04:00 | Rebuild all indexes | +| **Optimize GIN Indexes** | Sunday 05:00 | Keep GIN trigram indexes fresh | -## Backup and restore +> ⚠️ Avoid overlapping REINDEX, VACUUM and Backup in the same time window. -In plugin Configuration: +--- -1. Set default backup directory. -2. Optional on Windows: set `pg_dump` and `psql` paths if not in PATH. -3. Set default compression. +## ⚡ JellyTrend integration -In plugin Maintenance: +If you have [**JellyTrend**](https://github.com/BORNIOS/JellyTrend) installed, the recommendation engine automatically detects this plugin and replaces `ILibraryManager` queries with optimized native SQL: -1. Run manual backup (`.sql` or `.zip`). -2. Run restore from backup. -3. Optionally schedule recurring backups in Jellyfin Scheduled Tasks. +| Engine | Behavior | +|---|---| +| **SQLite** (without this plugin) | `ILibraryManager` — compatible with any installation | +| **PostgreSQL** (with this plugin) | Direct SQL with native `&&` array operator + GIN indexes — **4-10× faster** | -## Quick post-switch checks +> ✅ No manual setup needed. If both plugins are installed, the integration activates automatically. -- Login works with existing users. -- Playback progress/activity continues to update. -- No PostgreSQL connection errors in Jellyfin logs. +--- -## Short FAQ +## ✅ Post-migration checklist -Q: I installed the plugin but data was not migrated. +1. Login works with existing users. +2. Playback progress updating correctly. +3. Key row counts validated (UserData, Users, BaseItems). +4. No PostgreSQL connection errors in Jellyfin logs. +5. Health Check run without errors (`Warn` acceptable, `Error` requires action). +6. Manual backup tested at least once. +7. GIN indexes applied (**Maintenance → Apply optimizations**). -A: Expected. First run SQLite -> PostgreSQL migrator, then activate PostgreSQL in plugin UI. +--- -Q: Can I edit `database.xml` manually. +## ❓ FAQ -A: Yes, but plugin UI is recommended to reduce configuration mistakes. +
+I installed the plugin but data was not migrated. -Q: Migration fails or gives odd results on retries. +Expected. Run migration from **Migration → SQLite → PostgreSQL**, then activate PostgreSQL from **Configuration**. +
-A: On large databases or repeated migrations, enable --truncate to reset destination tables before insert. +
+Migration fails or gives odd results on retries. -## Community +Enable **Truncate tables before insert** to reset destination tables before inserting. Destructive on existing PostgreSQL data, but guarantees a clean result. +
-Questions or feedback? Open an issue or join the Jellyfin community: +
+Can I edit database.xml manually? + +Yes, but the plugin UI is recommended to avoid formatting errors. +
+ +
+What happens if I export to SQLite and the .db file already exists? + +The plugin preserves the table structure and replaces only the data (DELETE + INSERT). The file is not dropped or recreated. +
+ +
+Does the fast search require client changes? + +No. It is a server-side endpoint. The plugin UI uses it internally when PostgreSQL is active and GIN indexes exist. +
+ +
+Why do timestamps show UTC instead of my timezone? + +Earlier versions used the global `Npgsql.EnableLegacyTimestampBehavior` switch which forced UTC on all reads. Since v2.0.0.0 this was replaced by `DateTimeKindNormalizingInterceptor`, which only normalizes write parameters with `Kind=Unspecified`, respecting the PostgreSQL server timezone on reads. +
+ +--- + +## 🤝 Community + +Questions or feedback? Open an [issue](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/issues) or join the official Jellyfin community: [![Discord](https://img.shields.io/badge/Discord-Join_the_community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.jellyfin.org) [![Reddit](https://img.shields.io/badge/Reddit-r%2Fjellyfin-FF4500?style=for-the-badge&logo=reddit&logoColor=white)](https://www.reddit.com/r/jellyfin) + +--- + +
+ +Made with ❤️ for the Jellyfin community  ·  [⭐ Star on GitHub](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres) + +
diff --git a/README.md b/README.md index 9ac7a83..de8b553 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,285 @@ -# PostgreSQL para Jellyfin +
![Logo del plugin PostgreSQL](Jellyfin.Database.Providers.Postgres/Resources/logo.png) -Usa PostgreSQL como base de datos de Jellyfin sin modificar el core. +🌐  **Español**  ·  [**English**](README.en.md) -[![Jellyfin 10.11.x](https://img.shields.io/badge/Jellyfin-10.11.x-blue?style=flat-square)](https://jellyfin.org) -[![Release](https://img.shields.io/github/v/release/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest) +
+ +# 🐘 PostgreSQL Database Provider + +**Plugin para Jellyfin** que reemplaza SQLite por PostgreSQL como motor de base de datos, +sin modificar el núcleo de Jellyfin. Migración bidireccional, búsqueda casi instantánea, +health check automático, mantenimiento programado e integración opcional con JellyTrend. + +
+ +[![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) +[![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) -[![Reddit](https://img.shields.io/badge/Reddit-r%2Fjellyfin-FF4500?style=flat-square&logo=reddit&logoColor=white)](https://www.reddit.com/r/jellyfin) +[![License](https://img.shields.io/github/license/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=555)](LICENSE) -Idioma: [Español](README.md) | [English](README.en.md) +
-## Que resuelve +--- -- Mejor comportamiento con bibliotecas grandes y concurrencia. -- Backups y restore desde el plugin (pg_dump/psql). -- Cambio controlado entre PostgreSQL y SQLite. +## ✨ Características -## Compatibilidad +- 🐘 **PostgreSQL como backend nativo** — EF Core + Npgsql, sin tocar el core de Jellyfin. +- 🔍 **Búsqueda casi instantánea** (< 15 ms típico) con índice GIN trigram (`pg_trgm`); fallback automático a `ILIKE`. +- 🔄 **Migración bidireccional** — SQLite → PostgreSQL y PostgreSQL → SQLite sin herramientas externas. +- 🩺 **Health Check automático** al arrancar: diagnostica bloat, índices inválidos, secuencias desfasadas y queries lentas. +- 🛡️ **Prevención de errores** — interceptores EF Core para upserts, logging de errores DB y normalización de `DateTime.Kind`. +- 🔧 **Optimización automática** — índices GIN CONCURRENTLY, tuning de autovacuum en tablas críticas. +- 💾 **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. +- 🔁 **Rollback a SQLite** en un clic desde la configuración. -| Componente | Version | -| --- | --- | -| Jellyfin | 10.11.10 | -| PostgreSQL | 13+ (recomendado 16/17) | +--- -## Instalacion +## 🖥️ Interfaz del plugin -### Opcion A: desde repositorio de plugins (recomendado) +El plugin añade una página de configuración con **cuatro pestañas**: -1. En Jellyfin: Admin -> Plugins -> Plugin Repositories. -2. Agrega esta URL: +### ⚙️ Configuración -```text -https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json -``` +Gestiona la conexión, el pool de conexiones y las rutas de herramientas externas. -3. Guarda, refresca catalogo e instala PostgreSQL Database Provider. -4. Reinicia Jellyfin. +![Tab Configuración](Screenshots/Tab-Configurations.png) -### Opcion B: manual por ZIP +| 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` | -1. Descarga el ZIP de la ultima release. -2. Extrae en la carpeta de plugins de Jellyfin. -3. Reinicia Jellyfin. +**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. -## Vista rapida de la UI del plugin +--- -La pagina del plugin esta separada en 3 pestanas: +### 🩺 Health Check -- ⚙️ Configuracion: conexion PostgreSQL, timeout, rutas de binarios y defaults de backup. -- 🔄 Migracion: copia SQLite -> PostgreSQL con progreso en vivo. -- 🛠️ Mantenimiento: VACUUM, REINDEX, backups/restore y tabla de estadisticas. +Diagnóstico automático de la base de datos. Se ejecuta **10 segundos después del arranque** y puede lanzarse manualmente desde la UI. -## Configuracion recomendada (paso a paso) +![Tab Health Check](Screenshots/Tab-Health.png) -### 1) Conexion por formulario (no necesitas escribirla a mano) +Comprueba y reporta: -La UI construye internamente la cadena con estos campos: +| Check | Descripció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`) | -1. Host. -2. Puerto. -3. Base de datos. -4. Usuario. -5. Contrasena. -6. Pool maximo (opciones avanzadas). -7. Command timeout. +Los resultados se clasifican con semáforo: `Info` / `Warn` / `Error`. Los problemas marcados como reparables pueden corregirse en un clic desde la misma card. -Ejemplo de la cadena que se genera: +--- -```text -Host=127.0.0.1;Port=5432;Database=jellyfin;Username=jellyfin;Password=CHANGE_ME;Pooling=true;Maximum Pool Size=200 -``` +### 🔄 Migración -### 2) Valida antes de guardar +Dos operaciones disponibles con progreso en vivo: -Usa Probar conexion. +![Tab Migración](Screenshots/Tab-Migrations.png) -- Si todo bien: te devuelve Conexion exitosa + version del servidor PostgreSQL. -- Si falla: te devuelve error detallado para corregir host/credenciales/red. +#### SQLite → PostgreSQL -### 3) Guarda configuracion persistente +Copia `jellyfin.db` a PostgreSQL tabla por tabla. -Al guardar, se persisten: +1. La ruta de `jellyfin.db` se autodetecta (`{DataPath}/jellyfin.db`). +2. Ajusta el **batch size** (default 1000). +3. Activa **Truncar tablas antes de insertar** si repites la migración sobre datos existentes. +4. Pulsa **Iniciar migración** y sigue el progreso. +5. Al llegar al 100 %, activa PostgreSQL desde la pestaña Configuración. -- Connection string. -- Command timeout. -- Directorio default de backup. -- Compresion ZIP por defecto. -- Ruta de pg_dump (opcional). -- Ruta de psql (opcional). +> ⚠️ La opción `--truncate` hace `TRUNCATE + RESTART IDENTITY + CASCADE` en el destino. Úsala en reintentos para evitar duplicados; es destructiva con datos existentes. -## Como migrar de SQLite a PostgreSQL +#### PostgreSQL → SQLite -La migracion de datos no es automatica al instalar. +Exporta toda la base directamente a un archivo `.db` nativo sin herramientas externas. -1. Crea una base vacia en PostgreSQL (ejemplo: jellyfin). -2. En la pestana Configuracion, completa conexion y prueba. -3. Ve a Migracion y confirma ruta de jellyfin.db (se autodetecta si existe). -4. Ajusta batch size (default 1000). -5. Decide si activar Truncar tablas antes de insertar (--truncate). -6. Inicia migracion y espera progreso 100%. -7. Revisa filas copiadas y activa PostgreSQL. +- Si el `.db` no existe → lo crea con el schema derivado de PostgreSQL. +- Si el `.db` ya existe → preserva las tablas y reemplaza solo el contenido (`DELETE` + `INSERT`). +- Escritura en transacciones de 10 000 filas con `PRAGMA journal_mode=WAL`. -### Recomendacion sobre --truncate en DB grande +**Casos de uso:** revertir a SQLite, copia portable de seguridad, inspección local. -- ✅ Recomendado cuando vas a repetir migracion sobre una base PostgreSQL ya poblada. -- ✅ Recomendado en escenarios de reintento para evitar duplicados/inconsistencias por residuos. -- ⚠️ Destructivo sobre datos existentes en destino (hace TRUNCATE + RESTART IDENTITY + CASCADE). +--- -## Como activar PostgreSQL +### 🛠️ Mantenimiento -Desde Configuracion: +Operaciones de mantenimiento, backups y estadísticas de la base de datos. -1. Verifica que la conexion pase el test. -2. Activa PostgreSQL y reinicia. +![Tab Mantenimiento](Screenshots/Tab-Maintenance.png) -Resultado esperado: +| Acción | Descripció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 | -- `database.xml` se escribe en modo `PLUGIN_PROVIDER`. -- El provider activo pasa a `Jellyfin.Database.Providers.Postgres.dll`. +> 💡 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 | -## Como volver a SQLite (rollback) +--- -Desde Configuracion: +## 🔍 Búsqueda casi instantánea -1. Revertir a SQLite y reiniciar. -2. El plugin elimina `database.xml`. +Cuando los índices GIN están activos, el plugin expone un endpoint propio que evita EF Core: + +- Latencia típica **< 15 ms** en bibliotecas medianas y grandes (depende del hardware y del cliente). +- Búsqueda por nombre, path y tipo de ítem. +- **Fallback automático** a `ILIKE` si los índices GIN aún no existen. +- Se activa en **Mantenimiento → Aplicar optimizaciones**. + +--- + +## 🔁 Activar / Desactivar PostgreSQL -Cuando conviene rollback: +### Activar PostgreSQL -- Caida de conectividad a PostgreSQL. -- Mantenimiento urgente. -- Migracion incompleta o validacion funcional fallida. +1. Configura y prueba la conexión en la pestaña **Configuración**. +2. Migra los datos (**Migración → SQLite → PostgreSQL**). +3. Pulsa **Activar PostgreSQL** y confirma el reinicio. -## Backups y restore +Resultado: `database.xml` se escribe en modo `PLUGIN_PROVIDER` apuntando a este plugin. -### Donde configurar rutas de binarios +### Volver a SQLite (rollback) -En Configuracion: +1. En **Configuración** pulsa **Desactivar / Volver a SQLite**. +2. El plugin elimina `database.xml`. +3. Reinicia Jellyfin. + +Cuándo hacer rollback: caída de conectividad a PostgreSQL, mantenimiento urgente o migración incompleta. + +--- + +## 💾 Backups -1. Ruta de pg_dump (opcional). -2. Ruta de psql (opcional). +### Configurar -Si no defines rutas, el plugin intenta resolverlos desde PATH del sistema. +En **Configuración**: +- **PgBin path** — ruta a `pg_dump`/`psql`/`pg_restore`. Déjalo vacío para usar el PATH del sistema. +- **Backup directory** — carpeta destino de los archivos. +- **Backup compression** — activa ZIP del `.sql`. -### Backup manual +### Manual -En Mantenimiento -> Crear backup ahora. +En **Mantenimiento**: **Crear backup ahora** / **Restablecer backup** (acepta `.sql` o `.zip`). -- Genera .sql y opcionalmente .zip segun configuracion. -- Usa directorio persistido de backups. +--- -### Restore manual +## 📅 Tareas programadas -En Mantenimiento -> Restablecer backup. +| Tarea | Default | Descripción | +|---|---|---| +| **PostgreSQL Backup** | Diario 02:00 | Backup con `pg_dump` | +| **PostgreSQL VACUUM ANALYZE** | Domingo 03:00 | Libera espacio y actualiza estadísticas | +| **PostgreSQL REINDEX DATABASE** | Domingo 04:00 | Reconstruye todos los índices | +| **Optimize GIN Indexes** | Domingo 05:00 | Mantiene los índices GIN trigram frescos | -- Acepta .sql o .zip (con .sql dentro). -- Por seguridad operativa, restaura reemplazando objetos existentes en schema public. +> ⚠️ Evita solapar REINDEX, VACUUM y Backup en la misma ventana horaria. -## Estadisticas de base de datos (tabla de stats) +--- -En Mantenimiento -> Actualizar estadisticas. +## ⚡ Integración con JellyTrend -Muestra: +Si tienes instalado [**JellyTrend**](https://github.com/BORNIOS/JellyTrend), el motor de recomendaciones detecta automáticamente este plugin y sustituye las queries de `ILibraryManager` por SQL nativo optimizado: -- Tamano total de la base. -- Conexiones activas. -- Tabla por tabla: filas estimadas, tamano total, tamano de tabla, tamano de indices. +| Motor | Comportamiento | +|---|---| +| **SQLite** (sin este plugin) | `ILibraryManager` — compatible con cualquier instalación | +| **PostgreSQL** (con este plugin) | SQL directo con operador `&&` de arrays + índices GIN — **4-10× más rápido** | -Usa esta vista para detectar: +> ✅ Sin configuración manual. Si ambos plugins están instalados, la integración se activa sola. -- Tablas con crecimiento anormal. -- Bloat de indices (indices creciendo mucho vs tabla). -- Momentos adecuados para VACUUM/REINDEX. +--- -## Tareas de mantenimiento del plugin +## ✅ Checklist post-migración -Tambien aparecen en Tareas programadas de Jellyfin: +1. Login correcto con los usuarios existentes. +2. Progreso de reproducción actualizando correctamente. +3. Conteos clave validados (UserData, Users, BaseItems). +4. Sin errores de conexión PostgreSQL en logs de Jellyfin. +5. Health Check ejecutado sin errores (`Warn` aceptable, `Error` requiere acción). +6. Backup manual probado al menos una vez. +7. Índices GIN aplicados (**Mantenimiento → Aplicar optimizaciones**). -- PostgreSQL Backup: diario a las 02:00 (default). -- PostgreSQL VACUUM ANALYZE: domingo 03:00 (default). -- PostgreSQL REINDEX DATABASE: domingo 04:00 (default). +--- -Recomendacion de operacion: +## ❓ FAQ -- Programa REINDEX fuera de horario pico (puede tardar varios minutos). -- Evita solapar backup, vacuum y reindex a la misma hora. +
+Instalé el plugin pero no migró datos automáticamente. -## Recomendacion sobre optimizacion de DB en Jellyfin +Es el comportamiento esperado. Ejecuta la migración desde **Migración → SQLite → PostgreSQL** y luego activa PostgreSQL desde **Configuración**. +
-Si ya operas con PostgreSQL activo, prioriza las tareas de mantenimiento de este plugin (VACUUM/REINDEX/Backup). +
+La migración falla o da resultados raros en reintentos. -Si tienes tareas heredadas de optimizacion pensadas para SQLite, revisalas para evitar trabajo redundante durante ventanas criticas. +Activa **Truncar tablas antes de insertar** para reiniciar las tablas destino antes de insertar. Es destructivo sobre datos existentes en PostgreSQL, pero garantiza un resultado limpio. +
-## Checklist post-migracion +
+¿Puedo editar database.xml manualmente? -1. Login correcto con usuarios existentes. -2. Actividad y progreso de reproduccion actualizando. -3. Conteos clave validados (UserData, Users, TypedBaseItems). -4. Sin errores de conexion PostgreSQL en logs. -5. Backup manual probado al menos una vez. +Sí, pero se recomienda usar la UI del plugin para evitar errores de formato. +
-## FAQ corta +
+¿Qué pasa si exporto a SQLite y el .db ya existe? -P: Instale el plugin y no migro datos automaticamente. +El plugin preserva la estructura de tablas y reemplaza solo los datos (DELETE + INSERT). El archivo no se elimina ni recrea. +
-R: Es esperado. Debes ejecutar migracion y luego activar PostgreSQL. +
+¿La búsqueda rápida requiere cambios en el cliente Jellyfin? -P: Puedo editar `database.xml` manualmente. +No. Es un endpoint de servidor. La UI del plugin lo usa internamente cuando PostgreSQL está activo y los índices GIN existen. +
-R: Si, pero se recomienda hacerlo desde la UI del plugin para evitar errores. +
+¿Por qué los timestamps muestran UTC en lugar de mi zona horaria? -P: La migracion falla o da resultados raros en reintento. +En versiones anteriores se usaba el switch global `Npgsql.EnableLegacyTimestampBehavior` que forzaba UTC en todas las lecturas. Desde v2.0.0.0 esto se reemplazó por el interceptor `DateTimeKindNormalizingInterceptor`, que solo normaliza parámetros de escritura con `Kind=Unspecified`, respetando la timezone del servidor PostgreSQL en las lecturas. +
-R: En DB grande o migraciones repetidas, habilita --truncate para reiniciar tablas destino antes de insertar. +--- -## Comunidad +## 🤝 Comunidad -¿Dudas o feedback? Puedes abrir un issue o unirte a la comunidad Jellyfin: +¿Dudas, sugerencias o encontraste un bug? Abre un [issue](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/issues) o únete a la comunidad oficial de Jellyfin: -[![Discord](https://img.shields.io/badge/Discord-Unete_a_la_comunidad-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.jellyfin.org) +[![Discord](https://img.shields.io/badge/Discord-Únete_a_la_comunidad-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.jellyfin.org) [![Reddit](https://img.shields.io/badge/Reddit-r%2Fjellyfin-FF4500?style=for-the-badge&logo=reddit&logoColor=white)](https://www.reddit.com/r/jellyfin) + +--- + +
+ +Hecho con ❤️ para la comunidad Jellyfin  ·  [⭐ Star en GitHub](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres) + +
diff --git a/Screenshots/Tab-Configurations.png b/Screenshots/Tab-Configurations.png new file mode 100644 index 0000000..8937129 Binary files /dev/null and b/Screenshots/Tab-Configurations.png differ diff --git a/Screenshots/Tab-Health.png b/Screenshots/Tab-Health.png new file mode 100644 index 0000000..182c4db Binary files /dev/null and b/Screenshots/Tab-Health.png differ diff --git a/Screenshots/Tab-Maintenance.png b/Screenshots/Tab-Maintenance.png new file mode 100644 index 0000000..d63e336 Binary files /dev/null and b/Screenshots/Tab-Maintenance.png differ diff --git a/Screenshots/Tab-Migrations.png b/Screenshots/Tab-Migrations.png new file mode 100644 index 0000000..c05c32a Binary files /dev/null and b/Screenshots/Tab-Migrations.png differ diff --git a/build.yaml b/build.yaml index 58f0ac2..6eca1c9 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- name: "PostgreSQL Database Provider" guid: "a2b5f3e8-4c1d-4f7a-9e6b-8d0c2f1a3b5e" -version: "1.0.0.0" +version: "1.1.0.0" targetAbi: "10.11.10.0" framework: "net9.0" overview: "Use PostgreSQL as Jellyfin's database backend" diff --git a/jellyfin.ruleset b/jellyfin.ruleset new file mode 100644 index 0000000..cce071c --- /dev/null +++ b/jellyfin.ruleset @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manifest.json b/manifest.json index 986fe83..20351f3 100644 --- a/manifest.json +++ b/manifest.json @@ -2,11 +2,11 @@ { "guid": "a2b5f3e8-4c1d-4f7a-9e6b-8d0c2f1a3b5e", "name": "PostgreSQL Database Provider", - "description": "Provides PostgreSQL as the Jellyfin database backend via EF Core and Npgsql, including tooling to migrate an existing Jellyfin SQLite database into a PostgreSQL schema.", - "overview": "Use PostgreSQL as Jellyfin's database backend", - "owner": "BORNIOS", + "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.", + "owner": ".:BORNIOS:.", "category": "Database", - "imageUrl": "https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/v1.0.0.0/Jellyfin.Database.Providers.Postgres/Resources/logo.png", + "imageUrl": "https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/Jellyfin.Database.Providers.Postgres/Resources/logo.png", "versions": [ { "version": "1.0.0.0",