#Requires -Version 5.1 <# .SYNOPSIS Automated weekly performance monitoring for Jellyfin PostgreSQL database .DESCRIPTION This script: 1. Runs comprehensive diagnostics 2. Analyzes slow queries 3. Tracks index usage over time 4. Generates weekly performance reports 5. Optionally runs VACUUM ANALYZE on tables with high dead tuples 6. Can be scheduled via Task Scheduler .PARAMETER OutputDirectory Directory to save reports (default: reports/weekly) .PARAMETER EmailReport If specified, emails the report (requires email configuration) .PARAMETER CompareWithPrevious Compare current stats with previous week .EXAMPLE .\Weekly-Performance-Monitor.ps1 .EXAMPLE .\Weekly-Performance-Monitor.ps1 -OutputDirectory "C:\Reports\Jellyfin" -CompareWithPrevious .EXAMPLE .\Weekly-Performance-Monitor.ps1 -AutoVacuumAnalyze -DeadTupleThreshold 10000 -DeadTuplePercentThreshold 20 .EXAMPLE # Schedule weekly via Task Scheduler $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-File C:\Path\To\Weekly-Performance-Monitor.ps1" $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6am Register-ScheduledTask -TaskName "Jellyfin DB Monitor" -Action $action -Trigger $trigger .NOTES Author: Generated for Jellyfin PostgreSQL Project Date: 2026-03-05 #> [CmdletBinding()] param ( [Parameter()] [string]$OutputDirectory = "reports\weekly", [Parameter()] [switch]$EmailReport, [Parameter()] [switch]$CompareWithPrevious, [Parameter()] [switch]$AutoVacuumAnalyze, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int]$DeadTupleThreshold = 10000, [Parameter()] [ValidateRange(0.01, 100.0)] [double]$DeadTuplePercentThreshold = 20.0 ) # ============================================================================ # Configuration # ============================================================================ $ErrorActionPreference = "Stop" $ScriptRoot = Split-Path -Parent $PSCommandPath $ProjectRoot = Split-Path -Parent $ScriptRoot # Load database configuration . "$ProjectRoot\scripts\db-config.ps1" # ============================================================================ # Functions # ============================================================================ function Write-ColorOutput { param( [string]$Message, [ConsoleColor]$Color = 'White' ) Write-Host $Message -ForegroundColor $Color } function Get-ReportTimestamp { return Get-Date -Format "yyyy-MM-dd_HHmmss" } function Get-WeekNumber { $date = Get-Date $culture = [System.Globalization.CultureInfo]::CurrentCulture return $culture.Calendar.GetWeekOfYear($date, [System.Globalization.CalendarWeekRule]::FirstDay, [DayOfWeek]::Monday) } function Test-DatabaseConnection { Write-ColorOutput "`nTesting database connection..." -Color Cyan try { $result = & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "SELECT version();" 2>&1 if ($LASTEXITCODE -eq 0) { Write-ColorOutput "[OK] Database connection successful" -Color Green return $true } else { Write-ColorOutput "[ERROR] Database connection failed: $result" -Color Red return $false } } catch { Write-ColorOutput "[ERROR] Database connection error: $_" -Color Red return $false } } function Invoke-DiagnosticReport { param([string]$OutputFile) Write-ColorOutput "`nRunning comprehensive diagnostics..." -Color Cyan try { & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$ProjectRoot\sql\diagnostics.sql" > $OutputFile 2>&1 if ($LASTEXITCODE -eq 0) { Write-ColorOutput "[OK] Diagnostics completed: $OutputFile" -Color Green return $true } else { Write-ColorOutput "[WARN] Diagnostics completed with warnings" -Color Yellow return $true } } catch { Write-ColorOutput "[ERROR] Diagnostics failed: $_" -Color Red return $false } } function Invoke-QueryAnalysis { param([string]$OutputFile) Write-ColorOutput "`nAnalyzing slow queries..." -Color Cyan try { & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$ProjectRoot\sql\query-analysis.sql" > $OutputFile 2>&1 if ($LASTEXITCODE -eq 0) { Write-ColorOutput "[OK] Query analysis completed: $OutputFile" -Color Green return $true } else { Write-ColorOutput "[WARN] Query analysis completed with warnings" -Color Yellow return $true } } catch { Write-ColorOutput "[ERROR] Query analysis failed: $_" -Color Red return $false } } function Get-IndexUsageStats { param([string]$OutputFile) Write-ColorOutput "`nGathering index usage statistics..." -Color Cyan $query = @" SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog') ORDER BY schemaname, tablename, idx_scan DESC; "@ try { & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c $query > $OutputFile 2>&1 if ($LASTEXITCODE -eq 0) { Write-ColorOutput "[OK] Index stats gathered: $OutputFile" -Color Green return $true } else { Write-ColorOutput "[WARN] Index stats completed with warnings" -Color Yellow return $true } } catch { Write-ColorOutput "[ERROR] Index stats failed: $_" -Color Red return $false } } function Get-TableSizeStats { param([string]$OutputFile) Write-ColorOutput "`nGathering table size statistics..." -Color Cyan $query = @" SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size, pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS index_size, n_live_tup AS row_count, n_dead_tup AS dead_rows, ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct FROM pg_stat_user_tables WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog') ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; "@ try { & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c $query > $OutputFile 2>&1 if ($LASTEXITCODE -eq 0) { Write-ColorOutput "[OK] Table size stats gathered: $OutputFile" -Color Green return $true } else { Write-ColorOutput "[WARN] Table size stats completed with warnings" -Color Yellow return $true } } catch { Write-ColorOutput "[ERROR] Table size stats failed: $_" -Color Red return $false } } function Invoke-ConditionalVacuumAnalyze { param( [int]$MinDeadTuples, [double]$MinDeadPercent ) Write-ColorOutput "`nChecking for tables that need VACUUM ANALYZE..." -Color Cyan Write-ColorOutput "Thresholds: dead tuples >= $MinDeadTuples and dead percent >= $MinDeadPercent" -Color Gray $tableQuery = @" SELECT format('%I.%I', schemaname, tablename) AS qualified_table, n_dead_tup, ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct FROM pg_stat_user_tables WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog') AND n_live_tup > 0 AND n_dead_tup >= $MinDeadTuples AND (100.0 * n_dead_tup / NULLIF(n_live_tup, 0)) >= $MinDeadPercent ORDER BY n_dead_tup DESC; "@ try { $tables = & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -A -F "|" -c $tableQuery 2>&1 if ($LASTEXITCODE -ne 0) { Write-ColorOutput "[ERROR] Failed to evaluate dead tuple thresholds: $tables" -Color Red return $false } $tableLines = @($tables | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($tableLines.Count -eq 0) { Write-ColorOutput "[OK] No tables exceeded dead tuple thresholds" -Color Green return $true } Write-ColorOutput "Found $($tableLines.Count) table(s) requiring VACUUM ANALYZE" -Color Yellow foreach ($line in $tableLines) { $parts = $line -split "\|", 3 $qualifiedTable = $parts[0].Trim() $deadTuples = if ($parts.Count -ge 2) { $parts[1].Trim() } else { "unknown" } $deadPercent = if ($parts.Count -ge 3) { $parts[2].Trim() } else { "unknown" } Write-ColorOutput "Running VACUUM ANALYZE on $qualifiedTable (dead tuples: $deadTuples, dead percent: $deadPercent)" -Color Cyan $vacuumResult = & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "VACUUM (ANALYZE, VERBOSE) $qualifiedTable;" 2>&1 if ($LASTEXITCODE -ne 0) { Write-ColorOutput "[ERROR] VACUUM ANALYZE failed for $qualifiedTable: $vacuumResult" -Color Red return $false } Write-ColorOutput "[OK] VACUUM ANALYZE completed for $qualifiedTable" -Color Green } return $true } catch { Write-ColorOutput "[ERROR] Conditional VACUUM ANALYZE failed: $_" -Color Red return $false } } function Compare-WithPrevious { param( [string]$CurrentReport, [string]$OutputDirectory ) Write-ColorOutput "`nComparing with previous week's report..." -Color Cyan # Find most recent previous report $previousReports = Get-ChildItem "$OutputDirectory\diagnostics_*.txt" -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne (Split-Path $CurrentReport -Leaf) } | Sort-Object LastWriteTime -Descending if ($previousReports.Count -eq 0) { Write-ColorOutput "[WARN] No previous reports found for comparison" -Color Yellow return } $previousReport = $previousReports[0].FullName Write-ColorOutput "Comparing with: $($previousReports[0].Name)" -Color Cyan # TODO: Add detailed comparison logic # For now, just note that both reports exist Write-ColorOutput "[OK] Comparison data available" -Color Green Write-ColorOutput " Current: $CurrentReport" -Color Gray Write-ColorOutput " Previous: $previousReport" -Color Gray } function New-SummaryReport { param( [string]$OutputDirectory, [string]$Timestamp ) Write-ColorOutput "`nGenerating summary report..." -Color Cyan $summaryFile = Join-Path $OutputDirectory "summary_$Timestamp.txt" $summary = @" ================================================================================ JELLYFIN DATABASE WEEKLY PERFORMANCE SUMMARY ================================================================================ Report Date: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") Database: $DB_NAME at $DB_HOST Week Number: $(Get-WeekNumber) ================================================================================ REPORTS GENERATED: * diagnostics_$Timestamp.txt : Full diagnostic report * query_analysis_$Timestamp.txt : Slow query analysis * index_usage_$Timestamp.txt : Index usage statistics * table_sizes_$Timestamp.txt : Table size and bloat info * summary_$Timestamp.txt : This summary ================================================================================ QUICK HEALTH CHECK: ================================================================================ To view critical issues check diagnostics file for: * Section 5: TABLES WITH HIGH SEQUENTIAL SCANS * Section 8: UNUSED OR RARELY USED INDEXES * Section 10: SLOWEST QUERIES To identify slow queries check query_analysis file for: * Section 2: TOP 20 SLOWEST QUERIES (by max execution time) * Section 5: BASEITEMS TABLE QUERIES ANALYSIS ================================================================================ RECOMMENDED ACTIONS: ================================================================================ * Review slow queries taking longer than 10 seconds * Check if new indexes are being utilized * Look for tables with many sequential scans * Monitor cache hit ratio (should be above 95 percent) * Check for tables with high dead tuples percentage (need VACUUM) ================================================================================ AUTOMATION SETUP: ================================================================================ To schedule this script weekly via Task Scheduler run Setup-Weekly-Monitor.ps1 as Administrator. This will create a scheduled task to run every Monday at 6am. To disable the scheduled task run Setup-Weekly-Monitor.ps1 with -Uninstall flag. ================================================================================ NEXT REPORT: $(Get-Date).AddDays(7).ToString("yyyy-MM-dd") ================================================================================ "@ $summary | Out-File -FilePath $summaryFile -Encoding UTF8 Write-ColorOutput "[OK] Summary generated: $summaryFile" -Color Green } # ============================================================================ # Main Execution # ============================================================================ Write-ColorOutput "`n========================================" -Color Cyan Write-ColorOutput "Jellyfin Database Weekly Monitor" -Color Cyan Write-ColorOutput "========================================" -Color Cyan Write-ColorOutput "Database: $DB_NAME" -Color Yellow Write-ColorOutput "Host: $DB_HOST" -Color Yellow Write-ColorOutput "Week: $(Get-WeekNumber)" -Color Yellow Write-ColorOutput "Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -Color Yellow Write-ColorOutput "========================================`n" -Color Cyan # Create output directory $reportDir = Join-Path $ProjectRoot $OutputDirectory if (-not (Test-Path $reportDir)) { New-Item -ItemType Directory -Path $reportDir -Force | Out-Null Write-ColorOutput "Created report directory: $reportDir" -Color Green } # Generate timestamp for this run $timestamp = Get-ReportTimestamp # Test connection if (-not (Test-DatabaseConnection)) { Write-ColorOutput "`n[ERROR] Cannot connect to database. Exiting." -Color Red exit 1 } # Run diagnostics $diagnosticsFile = Join-Path $reportDir "diagnostics_$timestamp.txt" $success = Invoke-DiagnosticReport -OutputFile $diagnosticsFile if (-not $success) { Write-ColorOutput "`n[WARN] Some reports failed. Check output directory." -Color Yellow } # Run query analysis $queryAnalysisFile = Join-Path $reportDir "query_analysis_$timestamp.txt" Invoke-QueryAnalysis -OutputFile $queryAnalysisFile | Out-Null # Gather index usage stats $indexUsageFile = Join-Path $reportDir "index_usage_$timestamp.txt" Get-IndexUsageStats -OutputFile $indexUsageFile | Out-Null # Gather table size stats $tableSizesFile = Join-Path $reportDir "table_sizes_$timestamp.txt" Get-TableSizeStats -OutputFile $tableSizesFile | Out-Null # Optionally vacuum/analyze tables that exceed dead tuple thresholds if ($AutoVacuumAnalyze) { $vacuumSuccess = Invoke-ConditionalVacuumAnalyze ` -MinDeadTuples $DeadTupleThreshold ` -MinDeadPercent $DeadTuplePercentThreshold if (-not $vacuumSuccess) { Write-ColorOutput "[WARN] Conditional VACUUM ANALYZE encountered errors" -Color Yellow } } # Compare with previous week if requested if ($CompareWithPrevious) { Compare-WithPrevious -CurrentReport $diagnosticsFile -OutputDirectory $reportDir } # Generate summary New-SummaryReport -OutputDirectory $reportDir -Timestamp $timestamp # Cleanup old reports (keep last 12 weeks) Write-ColorOutput "`nCleaning up old reports (keeping last 12 weeks)..." -Color Cyan Get-ChildItem $reportDir -File | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-84) } | Remove-Item -Force Write-ColorOutput "[OK] Cleanup complete" -Color Green Write-ColorOutput "`n========================================" -Color Green Write-ColorOutput "[OK] WEEKLY MONITORING COMPLETE" -Color Green Write-ColorOutput "========================================" -Color Green Write-ColorOutput "Reports saved to: $reportDir" -Color Yellow Write-ColorOutput "Summary: summary_$timestamp.txt" -Color Yellow Write-ColorOutput "`nNext steps:" -Color Cyan Write-ColorOutput "1. Review summary_$timestamp.txt" -Color White Write-ColorOutput "2. Check for critical issues in diagnostics report" -Color White Write-ColorOutput "3. Analyze slow queries in query_analysis report" -Color White Write-ColorOutput "========================================`n" -Color Green # Email report if requested (placeholder) if ($EmailReport) { Write-ColorOutput "[WARN] Email functionality not yet implemented" -Color Yellow Write-ColorOutput " Configure Send-MailMessage with your SMTP settings" -Color Gray } exit 0