mirror of
https://github.com/ChuckPa/PlexDBRepair.git
synced 2025-11-05 18:58:53 -05:00
While it probably won't address #184 since the batch script works without issue, but I was unable to replicate this on my system, so this is at least an intermediate step. Also do some refactoring around the export process, and don't remove temp files in scripted mode if the last operation failed.
890 lines
35 KiB
PowerShell
890 lines
35 KiB
PowerShell
#########################################################################
|
|
# Plex Media Server database check and repair utility script. #
|
|
# #
|
|
#########################################################################
|
|
|
|
$PlexDBRepairVersion = 'v1.00.02'
|
|
|
|
class PlexDBRepair {
|
|
[PlexDBRepairOptions] $Options
|
|
|
|
[string] $PlexDBDir # Path to Plex's Databases directory
|
|
[string] $PlexCache # Path to the PhotoTranscoder directory
|
|
[string] $PlexSQL # Path to 'Plex SQLite.exe'
|
|
[string] $Timestamp # Timestamp used for temporary database files
|
|
[string] $LogFile # Path of our log file
|
|
[string] $Version # Current script version
|
|
[bool] $IsError # Whether we're currently in an error state
|
|
|
|
PlexDBRepair($Arguments, $Version) {
|
|
$this.Options = [PlexDBRepairOptions]::new()
|
|
$this.Version = $Version
|
|
$this.IsError = $false
|
|
$Commands = $this.PreprocessArgs($Arguments)
|
|
if ($null -eq $Commands) {
|
|
return
|
|
}
|
|
|
|
if (!$this.Init()) {
|
|
Write-Host "Unable to initialize script, cannot continue."
|
|
return
|
|
}
|
|
|
|
$this.PrintHeader($true)
|
|
$this.MainLoop($Commands)
|
|
}
|
|
|
|
[void] PrintHeader([boolean] $WriteToLog) {
|
|
$OS = [System.Environment]::OSVersion.Version
|
|
if ($WriteToLog) {
|
|
$this.WriteLog("============================================================")
|
|
$this.WriteLog("Session start: Host is Windows $($OS.Major) (Build $($OS.Build))")
|
|
}
|
|
|
|
Write-Host "`n"
|
|
Write-Host " Plex Media Server Database Repair Utility (Windows $($OS.Major), Build $($OS.Build))"
|
|
Write-Host " Version $($this.Version) "
|
|
Write-Host
|
|
}
|
|
|
|
[void] PrintHelp() {
|
|
# -Help doesn't write to the log, since our log file path isn't set.
|
|
$this.PrintHeader($false)
|
|
Write-Host "When run without arguments, starts an interactive session that displays available options"
|
|
Write-Host "and lets you select the operations you want to perform. Or, to run tasks automatically,"
|
|
Write-Host "provide them directly to the script, e.g. '.\DBRepair-Windows.ps1 Stop Prune Start Exit'"
|
|
Write-Host
|
|
$this.PrintOptions("Main Options")
|
|
Write-Host
|
|
Write-Host "Extra Options - These can only be specified once (last one wins)"
|
|
Write-Host
|
|
Write-Host " -CacheAge [int] - The date cutoff for pruned images. Defaults to pruning images over 30"
|
|
Write-Host " days old."
|
|
Write-Host
|
|
}
|
|
|
|
[void] PrintMenu() {
|
|
$this.PrintOptions("Select")
|
|
}
|
|
|
|
[void] PrintOptions([string]$Header) {
|
|
# NOTE: While Windows only supports a subset of DBRepair.sh's features, keep the command
|
|
# numbers the same as we attempt to reach feature parity
|
|
Write-Host
|
|
Write-Host $Header
|
|
Write-Host
|
|
Write-Host " 1 - 'stop' - Stop PMS."
|
|
Write-Host " 2 - 'automatic' - Check, Repair/Optimize, and Reindex Database in one step."
|
|
Write-Host
|
|
Write-Host " 7 - 'start' - Start PMS"
|
|
Write-Host
|
|
Write-Host " 21 - 'prune' - Prune (remove) old image files (jpeg,jpg,png) from PhotoTranscoder cache."
|
|
if ($this.Options.IgnoreErrors) {
|
|
Write-Host " 42 - 'honor' - Honor all database errors."
|
|
} else {
|
|
Write-Host " 42 - 'ignore' - Ignore duplicate/constraint errors."
|
|
}
|
|
Write-Host
|
|
Write-Host " 99 - 'quit' - Quit immediately. Keep all temporary files."
|
|
Write-Host " 'exit' - Exit with cleanup options."
|
|
Write-Host
|
|
Write-Host " 'menu x' - Show this menu in interactive mode, where x is on/off/yes/no"
|
|
}
|
|
|
|
# Do initial parsing of arguments that aren't part of the loop, returning
|
|
# the list of arguments that _should_ be processed in the loop.
|
|
#
|
|
# E.g. given "Stop Prune CacheAge 20 Start", this function will set the CacheAge
|
|
# to 20, and return "Stop Prune Start"
|
|
[System.Collections.ArrayList] PreprocessArgs([string[]] $Arguments) {
|
|
$FinalArgs = [System.Collections.ArrayList]::new()
|
|
for ($i = 0; $i -lt $Arguments.Count; ++$i) {
|
|
switch -Regex ($Arguments[$i]) {
|
|
'^-?(H(elp)?|\?)$' {
|
|
if ($Arguments.Count -gt 1) {
|
|
Write-Warning "Found -Help, ignoring extra arguments"
|
|
}
|
|
|
|
$this.PrintHelp()
|
|
return $null
|
|
}
|
|
'^-?CacheAge$' {
|
|
if ($i -eq $Arguments.Count - 1) {
|
|
Write-Warning "Found -CacheAge argument, but no value. Using default of 30 days"
|
|
Break
|
|
}
|
|
|
|
++$i
|
|
$Age = $Arguments[$i]
|
|
if (!($Age -match "^\d+$")) {
|
|
Write-Warning "Invalid -CacheAge value '$Age'. Using default of 30 days"
|
|
Break
|
|
}
|
|
|
|
$this.Options.CacheAge = [int]$Age
|
|
}
|
|
|
|
Default { $FinalArgs.Add($_) }
|
|
}
|
|
}
|
|
|
|
return $FinalArgs
|
|
}
|
|
|
|
# Setup variables required for this utility to work.
|
|
[bool] Init() {
|
|
$this.Timestamp = Get-Date -Format HH-mm-ss
|
|
|
|
$AppData = $this.GetAppDataDir()
|
|
$Success = $this.GetPlexDBDir($AppData) -and $this.GetPlexSQL() -and $this.GetPhotoTranscoderDir($AppData)
|
|
if ($Success) {
|
|
$this.LogFile = Join-Path $this.PlexDBDir -ChildPath "PlexDBRepair.log"
|
|
}
|
|
|
|
return $Success
|
|
}
|
|
|
|
# Core routine that loops over all provided commands and executes them in order.
|
|
[void] MainLoop([System.Collections.ArrayList] $Arguments) {
|
|
$this.Options.Scripted = $Arguments.Count -ne 0
|
|
$i = 0
|
|
$Argc = $Arguments.Count
|
|
$NullInput = 0
|
|
$EOFExit = $false
|
|
while ($true) {
|
|
$Choice = $null
|
|
if ($this.Options.Scripted) {
|
|
if ($i -eq $Argc) {
|
|
$Choice = "exit"
|
|
} else {
|
|
$Choice = $Arguments[$i++]
|
|
}
|
|
} else {
|
|
if ($this.Options.ShowMenu) {
|
|
$this.PrintMenu()
|
|
}
|
|
|
|
Write-Host
|
|
$Choice = Read-Host "Enter command # -or- command name (4 char min)"
|
|
if ($Choice -eq "") {
|
|
++$NullInput
|
|
if ($NullInput -eq 5) {
|
|
$this.Output("Unexpected EOF / End of command line options. Exiting. Keeping temp files. ")
|
|
$Choice = "exit"
|
|
$EOFExit = $true
|
|
} else {
|
|
if ($NullInput -eq 4) {
|
|
Write-Warning "Next empty command exits as EOF. "
|
|
}
|
|
|
|
continue
|
|
}
|
|
} else {
|
|
$NullInput = 0
|
|
}
|
|
}
|
|
|
|
# Update timestamp
|
|
$this.Timestamp = Get-Date -Format 'yyyy-MM-dd_HH.mm.ss'
|
|
|
|
switch -Regex ($Choice) {
|
|
"^(1|stop)$" { $this.DoStop() }
|
|
"^(2|autom?a?t?i?c?)$" {
|
|
$this.IsError = !$this.RunAutomaticDatabaseMaintenance()
|
|
}
|
|
"^(7|start?)$" { $this.StartPMS() }
|
|
"^(21|(prune?|remov?e?))$" { $this.PrunePhotoTranscoderCache() }
|
|
"^(42|ignor?e?|honor?)$" {
|
|
if (($this.Options.IgnoreErrors -and ($Choice[0] -eq 'i')) -or (!$this.Options.IgnoreErrors -and ($Choice[0] -eq 'h'))) {
|
|
Write-Host "Honor/Ignore setting unchanged."
|
|
Break
|
|
}
|
|
|
|
$this.Options.IgnoreErrors = !$this.Options.IgnoreErrors
|
|
$msg = if ($this.Options.IgnoreErrors) { "Ignoring database errors." } else { "Honoring database errors." }
|
|
$this.WriteOutputLog($msg)
|
|
}
|
|
"^(99|quit)$" {
|
|
$this.Output("Retaining all remporary work files.")
|
|
$this.WriteLog("Exit - Retain temp files.")
|
|
$this.WriteEnd()
|
|
return
|
|
}
|
|
"^exit$" {
|
|
if ($EOFExit) {
|
|
$this.Output("Unexpected exit command. Keeping all temporary work files.")
|
|
$this.WriteLog("EOFExit - Retain temp files.")
|
|
return
|
|
}
|
|
|
|
# If our last DB operation failed, we don't want to automatically delete
|
|
# temporary files when in scripted mode.
|
|
if ($this.IsError -and $this.Options.Scripted) {
|
|
$this.Output("Exiting with errors. Keeping all temporary work files.")
|
|
$this.WriteLog("Exit - Retain temp files.")
|
|
return
|
|
}
|
|
|
|
$this.CleanDBTemp(!$this.Options.Scripted)
|
|
$this.WriteEnd()
|
|
return
|
|
}
|
|
"^menu\b" {
|
|
$Match = $Choice -match "^menu\s+(on|off|yes|no)"
|
|
if (!$Match) {
|
|
$this.OutputWarn("Invalid 'menu' format. Expected 'menu on/off/yes/no', got '$Choice'")
|
|
Break
|
|
}
|
|
|
|
$TurnOn = ($Matches.1 -eq 'on') -or ($Matches.1 -eq 'yes');
|
|
$this.Options.ShowMenu = $TurnOn
|
|
if (!$TurnOn) {
|
|
Write-Host "Menu off: Reenable with 'menu on' command"
|
|
}
|
|
}
|
|
Default {
|
|
$this.OutputWarn("Unknown Command: '$Choice'")
|
|
$this.WriteLog("Unknown command: '$Choice'")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Attempt to stop Plex Media Server if it's running
|
|
[void] DoStop() {
|
|
$this.WriteLog("Stop - START")
|
|
$PMS = $this.GetPMS()
|
|
if ($null -eq $PMS) {
|
|
$this.Output("PMS already stopped.")
|
|
return
|
|
}
|
|
|
|
$this.Output("Stopping PMS.")
|
|
|
|
# Plex doesn't respond to CloseMainWindow because it doesn't have a window,
|
|
# and Stop-Process does a forced exit of the process, so use taskkill to ask
|
|
# PMS to close nicely, and bail if that doesn't work.
|
|
$ErrorText = $null
|
|
Invoke-Expression "taskkill /im ""Plex Media Server.exe""" 2>$null -ErrorVariable ErrorText
|
|
if ($ErrorText) {
|
|
$this.WriteOutputLogWarn("Failed to send terminate signal to PMS, please stop manually.")
|
|
$this.WriteOutputLogWarn($ErrorText -join "`n")
|
|
return
|
|
}
|
|
|
|
$PMS.WaitForExit(30000) *>$null # Wait at most 30 seconds for PMS to close. If it still hasn't by then, bail.
|
|
if ($PMS.HasExited) {
|
|
$this.WriteLog("Stop - PASS")
|
|
$this.Output("Stopped PMS.")
|
|
return
|
|
}
|
|
|
|
$this.OutputWarn("Could not stop PMS. PMS did not shutdown within 30 second limit.")
|
|
$this.WriteLog("Stop - FAIL (Timeout)")
|
|
}
|
|
|
|
# Start Plex Media Server if it isn't already running
|
|
[void] StartPMS() {
|
|
$this.WriteLog("Start - START")
|
|
if ($this.PMSRunning()) {
|
|
$this.Output("Start not needed. PMS is running.")
|
|
$this.WriteLog("Start - PASS - PMS is already running")
|
|
return
|
|
}
|
|
|
|
$PMS = Join-Path (Split-Path -Parent $this.PlexSQL) -ChildPath "Plex Media Server.exe"
|
|
try {
|
|
Start-Process $PMS -EA Stop
|
|
$this.Output("Started PMS")
|
|
$this.WriteLog("Start - PASS")
|
|
} catch {
|
|
$Err = $Error -join "`n"
|
|
$this.OutputWarn("Could not start PMS: $Err")
|
|
$Error.Clear()
|
|
}
|
|
}
|
|
|
|
# All-in-one database utility - Repair/Check/Reindex
|
|
[bool] RunAutomaticDatabaseMaintenance() {
|
|
$this.Output("Automatic Check,Repair,Index started.")
|
|
$this.WriteLog("Auto - START")
|
|
|
|
if ($this.PMSRunning()) {
|
|
$this.WriteLog("Auto - FAIL - PMS running")
|
|
$this.OutputWarn("Unable to run automatic sequence. PMS is running. Please stop PlexMediaServer.")
|
|
return $false
|
|
}
|
|
|
|
# Create temporary backup directory
|
|
$DBTemp = Join-Path $this.PlexDBDir -ChildPath "dbtmp"
|
|
if (!$this.DirExists($DBTemp)) {
|
|
$TempDirError = $null
|
|
New-Item -Path $DBTemp -ItemType "directory" -ErrorVariable tempDirError *>$null
|
|
if ($TempDirError) {
|
|
$this.ExitDBMaintenance("Unable to create temporary database directory", $false)
|
|
return $false
|
|
}
|
|
}
|
|
|
|
$this.Output("Exporting Main DB")
|
|
$MainDBName = "com.plexapp.plugins.library.db"
|
|
$MainDB = Join-Path $this.PlexDBDir -ChildPath $MainDBName
|
|
$MainDBSQL = Join-Path $DBTemp -ChildPath "library.sql_$($this.TimeStamp)"
|
|
if (!$this.FileExists($MainDB)) {
|
|
$this.ExitDBMaintenance("Could not find $MainDBName in database directory", $false)
|
|
return $false
|
|
}
|
|
|
|
if (!$this.ExportPlexDB($MainDB, $MainDBSQL)) { return $false }
|
|
|
|
$this.Output("Exporting Blobs DB")
|
|
$BlobsDBName = "com.plexapp.plugins.library.blobs.db"
|
|
$BlobsDB = Join-Path $this.PlexDBDir -ChildPath $BlobsDBName
|
|
$BlobsDBSQL = Join-Path $DBTemp -ChildPath "blobs.sql_$($this.Timestamp)"
|
|
if (!$this.FileExists($BlobsDB)) {
|
|
$this.ExitDBMaintenance("Could not find $BlobsDBName in database directory", $false)
|
|
return $false
|
|
}
|
|
|
|
if (!$this.ExportPlexDB($BlobsDB, $BlobsDBSQL)) { return $false }
|
|
|
|
$this.Output("Successfully exported the main and blobs databases. Proceeding to import into new database.")
|
|
$this.WriteLog("Repair - Export databases - PASS")
|
|
|
|
# Make sure Plex hasn't been started while we were exporting
|
|
if (!$this.CheckPMS("Auto ", "export")) { return $false }
|
|
|
|
$this.Output("Importing Main DB.")
|
|
$MainDBImport = Join-Path $this.PlexDBDir -ChildPath "${MainDBName}_$($this.Timestamp)"
|
|
if (!$this.ImportPlexDB($MainDBSQL, $MainDBImport)) { return $false }
|
|
|
|
$this.Output("Importing Blobs DB.")
|
|
$BlobsDBImport = Join-Path $this.PlexDBDir -ChildPath "${BlobsDBName}_$($this.Timestamp)"
|
|
if (!$this.ImportPlexDB($BlobsDBSQL, $BlobsDBImport)) { return $false }
|
|
|
|
$this.Output("Successfully imported databases.")
|
|
$this.WriteLog("Repair - Import - PASS")
|
|
|
|
$this.Output("Verifying databases integrity after importing.")
|
|
|
|
if (!$this.IntegrityCheck($MainDBImport, "Main")) { return $false }
|
|
$this.Output("Verification complete. PMS main database is OK.")
|
|
$this.WriteLog("Repair - Verify main database - PASS")
|
|
|
|
if (!$this.IntegrityCheck($BlobsDBImport, "Blobs")) { return $false }
|
|
$this.Output("Verification complete. PMS blobs database is OK.")
|
|
$this.WriteLog("Repair - Verify blobs database - PASS")
|
|
|
|
if (!$this.CheckPMS("Auto ", "import")) { return $false }
|
|
|
|
# Import complete, now reindex
|
|
$this.WriteOutputLog("Reindexing Main DB")
|
|
if (!$this.RunSQLCommand("""$MainDBImport"" ""REINDEX;""", "Failed to reindex Main DB")) { return $false }
|
|
$this.WriteOutputLog("Reindexing Blobs DB")
|
|
if (!$this.RunSQLCommand("""$BlobsDBImport"" ""REINDEX;""", "Failed to reindex Blobs DB")) { return $false }
|
|
$this.WriteOutputLog("Reindexing complete.")
|
|
|
|
$this.WriteOutputLog("Moving current DBs to DBTMP and making new databases active")
|
|
if (!$this.CheckPMS("Auto ", "new database copy")) { return $false }
|
|
|
|
try {
|
|
$this.MoveDatabase($MainDB, (Join-Path $DBTemp -ChildPath "${MainDBName}_$($this.Timestamp)"), "move Main DB to DBTMP")
|
|
$this.MoveDatabase($MainDBImport, $MainDB, "replace Main DB with rebuilt DB")
|
|
|
|
$this.MoveDatabase($BlobsDB, (Join-Path $DBTemp -ChildPath "${BlobsDBName}_$($this.Timestamp)"), "move Blobs DB to DBTMP")
|
|
$this.MoveDatabase($BlobsDBImport, $BlobsDB, "replace Blobs DB with rebuilt DB")
|
|
} catch {
|
|
$Error.Clear()
|
|
return $false
|
|
}
|
|
|
|
$this.ExitDBMaintenance("Database repair/rebuild/reindex completed.", $true)
|
|
return $true
|
|
}
|
|
|
|
# Return whether we can continue DB repair (i.e. whether PMS is running) at the given stage in the process.
|
|
[bool] CheckPMS([string] $Stage, [string] $SubStage) {
|
|
if ($this.PMSRunning()) {
|
|
$SubMessage = if ($SubStage) { "during $SubStage" } else { "" }
|
|
$this.WriteLog("$Stage - FAIL - PMS running $SubMessage")
|
|
$this.OutputWarn("Unable to run $Stage. PMS is running. Please stop PlexMediaServer.")
|
|
return $false
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
# Try to move the source file to the destination. If it fails, attempt to find
|
|
# open file handles (requires handle.exe on PATH) and throw.
|
|
[void] MoveDatabase([string] $Source, [string] $Destination, [string] $FriendlyString) {
|
|
$MoveError = $null
|
|
Move-Item -Path $Source -Destination $Destination -ErrorVariable MoveError *>$null
|
|
if ($MoveError) {
|
|
$this.ExitDBMaintenance("Unable to $($FriendlyString): $MoveError", $false)
|
|
throw "Unable to move database"
|
|
}
|
|
}
|
|
|
|
# Attempts to prune PhotoTranscoder images that are older than the specified date cutoff (30 days by default)
|
|
[void] PrunePhotoTranscoderCache() {
|
|
$this.WriteLog("Prune - START")
|
|
if ($this.PMSRunning()) {
|
|
$this.OutputWarn("Unable to prune Phototranscoder cache. PMS is running.")
|
|
$this.WriteLog("Prune - FAIL - PMS running")
|
|
return
|
|
}
|
|
|
|
$Cutoff = $this.Options.CacheAge
|
|
$ShouldPrune = $this.Options.Scripted
|
|
if (!$ShouldPrune) {
|
|
$this.Output("Counting how many files are more than $Cutoff days old")
|
|
$CacheResult = $this.CheckPhotoTranscoderCache($true)
|
|
$Prunable = $CacheResult.PrunableFiles
|
|
$SpaceSaved = $CacheResult.SpaceSavings
|
|
|
|
if ($Prunable -eq 0) {
|
|
$this.Output("No files found to prune.")
|
|
$this.WriteLog("Prune - PASS (no files found to prune)")
|
|
return
|
|
}
|
|
|
|
$ShouldPrune = $this.GetYesNo("OK to prune $Prunable files ($SpaceSaved)")
|
|
}
|
|
|
|
if ($ShouldPrune) {
|
|
$this.Output("Pruning started.")
|
|
$PruneResult = $this.CheckPhotoTranscoderCache($false)
|
|
$Pruned = $PruneResult.PrunableFiles
|
|
$Total = $PruneResult.TotalFiles
|
|
$Saved = $PruneResult.SpaceSavings
|
|
$this.WriteOutputLog("Prune - Removed $Pruned files over $Cutoff days old ($Saved), out of $Total total files")
|
|
$this.Output("Pruning completed.")
|
|
} else {
|
|
$this.WriteOutputLog("Prune - Prune cancelled by user")
|
|
}
|
|
|
|
$this.WriteLog("Prune - PASS")
|
|
}
|
|
|
|
# Traverses PhotoTranscoder cache to find and delete files older than the specified max age.
|
|
# If $DryRun is $true, don't remove items, just gather statistics.
|
|
[CleanCacheResult] CheckPhotoTranscoderCache([bool] $DryRun) {
|
|
$Cutoff = (Get-Date).AddDays(-$this.Options.CacheAge);
|
|
$AllFiles = 0;
|
|
$OldFiles = 0;
|
|
$FreedBytes = 0;
|
|
Get-ChildItem -Path $this.PlexCache -Recurse -File |
|
|
Where-Object { $_.extension -in '.jpg','.jpeg','.png','.ppm' } |
|
|
ForEach-Object {
|
|
$AllFiles++;
|
|
if ($_.LastWriteTime -lt $Cutoff) {
|
|
$OldFiles++;
|
|
$FreedBytes += $_.Length;
|
|
if (!$DryRun) {
|
|
Remove-Item $_.FullName;
|
|
}
|
|
}
|
|
};
|
|
|
|
return [CleanCacheResult]::new($AllFiles, $OldFiles, $FreedBytes)
|
|
}
|
|
|
|
### Helpers ###
|
|
|
|
### Logging Helpers ###
|
|
|
|
[string] Now() { return Get-Date -Format 'yyyy-MM-dd HH.mm.ss' }
|
|
|
|
# Write the given text to the console
|
|
[void] Output([string] $Text) {
|
|
if ($this.Options.Scripted) {
|
|
Write-Host "$($this.Now()) $Text"
|
|
} else {
|
|
Write-Host $Text
|
|
}
|
|
}
|
|
|
|
# Write the given text as a warning in the console
|
|
[void] OutputWarn([string] $Text) {
|
|
if ($this.Options.Scripted) {
|
|
Write-Warning "$($this.Now()) $Text"
|
|
} else {
|
|
Write-Warning $Text
|
|
}
|
|
}
|
|
|
|
# Write the given text to the log file
|
|
[void] WriteLog([string] $Text) {
|
|
Add-Content -Path $this.LogFile -Value "$($this.Now()) -- $($Text)"
|
|
}
|
|
|
|
# Write the given text to the log file and console
|
|
[void] WriteOutputLog([string] $Text) {
|
|
$this.WriteLog($Text)
|
|
$this.Output($Text)
|
|
}
|
|
|
|
# Write the given text to the log file and as warning text in the console
|
|
[void] WriteOutputLogWarn([string] $Text) {
|
|
$this.WriteLog($Text)
|
|
$this.OutputWarn($Text)
|
|
}
|
|
|
|
# Write out the end of the session
|
|
[void] WriteEnd() {
|
|
$this.WriteLog("Session end. $(Get-Date)")
|
|
$this.WriteLog("============================================================")
|
|
}
|
|
|
|
### File Helpers ###
|
|
|
|
# Check whether the given directory exists (and is a directory)
|
|
[bool] DirExists([string] $Dir) {
|
|
if ($Dir) {
|
|
return Test-Path $Dir -PathType Container
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
# Check whether the given file exists (and is a file)
|
|
[bool] FileExists([string] $File) {
|
|
if ($File) {
|
|
return Test-Path $File -PathType Leaf
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
### Setup Helpers ###
|
|
|
|
# Retrieve Plex's data directory, exiting the script on falure
|
|
[string] GetAppDataDir() {
|
|
$PMSRegistry = $this.GetHKCU()
|
|
$PlexAppData = $PMSRegistry.LocalAppDataPath
|
|
if ($PlexAppData) {
|
|
$PlexAppData = Join-Path -Path $PlexAppData -ChildPath "Plex Media Server"
|
|
}
|
|
|
|
if ($this.DirExists($PlexAppData)) {
|
|
return $PlexAppData
|
|
}
|
|
|
|
$PlexAppData = "$env:LOCALAPPDATA\Plex Media Server"
|
|
if ($this.DirExists($PlexAppData)) {
|
|
return $PlexAppData
|
|
}
|
|
|
|
Write-Host "Could not determine Plex data directory, cannot continue"
|
|
Write-Host "Normally $env:LOCALAPPDATA\Plex Media Server"
|
|
exit
|
|
}
|
|
|
|
# Retrieve PMS settings under HKEY_CURRENT_USER, exiting the script on failure
|
|
[PSCustomObject] GetHKCU() {
|
|
try {
|
|
return (Get-ItemProperty -path 'HKCU:\Software\Plex, Inc.\Plex Media Server' -EA Stop)
|
|
} catch {
|
|
Write-Warning "Could not find Plex registry settings (HKCU\Software\Plex, Inc.\Plex Media Server). Are you sure Plex is installed on this machine?"
|
|
exit
|
|
}
|
|
}
|
|
|
|
# Set the Plex database directory, returning whether we found the directory
|
|
[bool] GetPlexDBDir([string] $AppData) {
|
|
$DBDir = Join-Path -Path $AppData -ChildPath "Plug-in Support\Databases"
|
|
if ($this.DirExists($DBDir)) {
|
|
$this.PlexDBDir = $DBDir;
|
|
return $true;
|
|
}
|
|
|
|
Write-Host "Could not find Databases folder, cannot continue."
|
|
Write-Host "Normally $DBDir"
|
|
return $false
|
|
}
|
|
|
|
# Set the path to Plex's PhotoTranscoder cache, returning whether we found the directory.
|
|
[bool] GetPhotoTranscoderDir([string] $AppData) {
|
|
$CacheDir = Join-Path -Path $AppData -ChildPath "Cache\PhotoTranscoder"
|
|
if ($this.DirExists($CacheDir)) {
|
|
$this.PlexCache = $CacheDir
|
|
return $true
|
|
}
|
|
|
|
Write-Host "Could not find PhotoTranscoder path, cannot prune."
|
|
Write-Host "Normally $CacheDir"
|
|
return $false
|
|
}
|
|
|
|
# Find the path to Plex SQLite.exe, falling back to user input if necessary.
|
|
[bool] GetPlexSQL() {
|
|
$PMSRegistry = $this.GetHKCU()
|
|
$InstallDir = $PMSRegistry.InstallFolder
|
|
if (!$InstallDir) {
|
|
# Install location might also be in HKLM
|
|
$InstallDir = (Get-ItemProperty -path 'HKLM:\SOFTWARE\Plex, Inc.\Plex Media Server' -EA Ignore).InstallFolder
|
|
if (!$InstallDir) {
|
|
# Final registry attempt - WOW6432Node
|
|
$InstallDir = (Get-ItemProperty -path 'HKLM:\SOFTWARE\WOW6432Node\Plex, Inc.\Plex Media Server' -EA Ignore).InstallFolder
|
|
}
|
|
}
|
|
|
|
$SQL = if ($InstallDir) { Join-Path -Path $InstallDir -ChildPath "Plex SQLite.exe" } else { $null }
|
|
if ($this.FileExists($SQL)) {
|
|
$this.PlexSQL = $SQL
|
|
return $true
|
|
}
|
|
|
|
# Still couldn't find install directory. Try standard PROGRAMFILES variables
|
|
$SQL = "$env:PROGRAMFILES\Plex\Plex Media Server\Plex SQ Lite.exe"
|
|
if ($this.FileExists($SQL)) {
|
|
$this.PlexSQL = $SQL
|
|
return $true
|
|
}
|
|
|
|
if (${env:PROGRAMFILES(X86)}) {
|
|
$SQL = "${env:PROGRAMFILES(X86)}\Plex Plex Media Server\Plex SQLite.exe"
|
|
if ($this.FileExists($SQL)) {
|
|
Write-Host "Note: 32-bit version of PMS detected on a 64-bit version of Windows. Using the 64-bit release of PMS is recommended."
|
|
$this.PlexSQL = $SQL
|
|
return $true
|
|
}
|
|
}
|
|
|
|
Write-Host "Could not determine Plex SQLite location. Please provide it below"
|
|
Write-Host "Normally $env:PORGRAMFILES\Plex\Plex Media Server\Plex SQLite.exe"
|
|
$First = $true
|
|
while (!$this.FileExists($SQL)) {
|
|
if (!$First) {
|
|
Write-Host "ERROR: '$SQL' could not be found"
|
|
}
|
|
|
|
$First = $false
|
|
$SQL = Read-Host -Prompt "Path to Plex SQLite.exe (Ctrl+C to cancel): "
|
|
}
|
|
|
|
$this.PlexSQL = $SQL
|
|
return $true
|
|
}
|
|
|
|
### Database Helpers ###
|
|
|
|
# Writes to output/log when we're done with database maintenance (on success or failure)
|
|
[void] ExitDBMaintenance([string] $Message, [boolean] $Success) {
|
|
if ($Success) {
|
|
$this.Output("Automatic Check,Repair,Index succeeded.")
|
|
$this.WriteLog("Auto - PASS")
|
|
} else {
|
|
$this.OutputWarn("Automatic maintenance failed - $Message")
|
|
$this.WriteLog("Auto - $Message, cannot continue.")
|
|
$this.WriteLog("Auto - FAIL")
|
|
}
|
|
}
|
|
|
|
[bool] ExportPlexDB([string] $Source, [string] $Destination) {
|
|
return $this.RunSQLCommand("""$Source"" "".output '$Destination'"" .dump", "Failed to export '$Source' to '$Destination'")
|
|
}
|
|
|
|
# Run an SQL command.
|
|
# ErrorMessage is the message to output/write to the log on failure
|
|
[bool] RunSQLCommand([string] $Command, [string] $ErrorMessage) {
|
|
return $this.RunSQLCommandCore($Command, $ErrorMessage, $null)
|
|
}
|
|
|
|
# Run an SQL command and retrieve the output of said command
|
|
# ErrorMessage is the message to output/write to the log on failure
|
|
[bool] GetSQLCommandResult([string] $Command, [string] $ErrorMessage, [ref] $Output) {
|
|
return $this.RunSQLCommandCore($Command, $ErrorMessage, $Output)
|
|
}
|
|
|
|
# Run a 'Plex SQLite' command
|
|
[bool] RunSQLCommandCore([string] $Command, [string] $ErrorMessage, [ref] $Output) {
|
|
$SqlError = $null
|
|
$SqlResult = $null
|
|
$ExitCode = 0
|
|
try {
|
|
Invoke-Expression "& ""$($this.PlexSQL)"" $Command" -ev sqlError -OutVariable sqlResult -EA Stop *>$null
|
|
$ExitCode = $LASTEXITCODE
|
|
} catch {
|
|
$Err = $Error -join "`n"
|
|
$this.ExitDBMaintenance("Failed to run command '$Command': '$Err'", $false)
|
|
$Error.Clear()
|
|
return $false
|
|
}
|
|
|
|
if ($SqlError -or $ExitCode) {
|
|
$Err = $SqlError -join "`n"
|
|
if (!$Err) { $Err = "Process exited with error code $ExitCode" }
|
|
$Msg = $ErrorMessage
|
|
if (!$Msg) {
|
|
$Msg = "Plex SQLite operation failed"
|
|
}
|
|
|
|
if ($this.Options.IgnoreErrors -and $this.Options.CanIgnore) {
|
|
$this.OutputWarn("Ignoring database errors - ${Msg}: $Err")
|
|
} else {
|
|
$this.ExitDBMaintenance("${Msg}: $Err", $false)
|
|
return $false
|
|
}
|
|
}
|
|
|
|
if ($null -ne $Output.Value) {
|
|
$Output.Value = $SqlResult
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
# Import an exported .sql file into a new database
|
|
[bool] ImportPlexDB($Source, $Destination) {
|
|
# SQLite's .read can't handle files larger than 2GB on versions <3.45.0 (https://sqlite.org/forum/forumpost/9af57ba66fbb5349),
|
|
# and Plex SQLite is currently on 3.39.4 (as of PMS 1.41.6).
|
|
# If the source is smaller than 2GB we can .read it directly, otherwise do things in a more roundabout way.
|
|
if ($this.FileExists($Source) -and (Get-Item $Source).Length -lt 2GB) {
|
|
return $this.RunSQLCommand("""$Destination"" "".read '$Source'""", "Failed to import Plex database (importing '$Source' into '$Destination')")
|
|
}
|
|
|
|
$ImportError = $null
|
|
$ExitCode = 0
|
|
$Err = $null
|
|
try {
|
|
# Use Start-Process, since PowerShell doesn't have '<', and alternatives ("Get-Content X | SQLite.exe OutDB") are subpar at best when dealing with large files like these database exports.
|
|
$process = Start-Process $this.PlexSQL -ArgumentList @("""$Destination""") -RedirectStandardInput $Source -NoNewWindow -Wait -PassThru -EA Stop -ErrorVariable ImportError
|
|
$ExitCode = $process.ExitCode
|
|
} catch {
|
|
$Err = $Error -join "`n"
|
|
$Error.Clear()
|
|
}
|
|
|
|
if ($ImportError) {
|
|
$Err = $ImportError -join "`n"
|
|
} elseif ($ExitCode) {
|
|
if ($this.Options.IgnoreErrors) {
|
|
$this.OutputWarn("Ignoring errors found during import")
|
|
} else {
|
|
$Err = "Process exited with error code $ExitCode (constraint error?)"
|
|
}
|
|
}
|
|
|
|
if ($Err) {
|
|
$this.ExitDBMaintenance("Failed to import Plex database (importing '$Source' into '$Destination'): $Err", $false)
|
|
return $false
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
[bool] IntegrityCheck([string] $Database, [string] $DbName) {
|
|
$this.Options.CanIgnore = $false
|
|
$VerifyResult = ""
|
|
$result = $this.GetSQLCommandResult("""$Database"" ""PRAGMA integrity_check(1)""", "Failed to verify $dbName DB", [ref]$VerifyResult)
|
|
if ($result) {
|
|
$this.Output("$DbName DB verification check is: $VerifyResult")
|
|
if ($VerifyResult -ne "ok") {
|
|
$this.ExitDBMaintenance("$DbName DB verification failed: $VerifyResult", $false)
|
|
$result = $false
|
|
}
|
|
}
|
|
|
|
$this.Options.CanIgnore = $false
|
|
return $result
|
|
}
|
|
|
|
# Clear out the temp database directory. If $Confirm is $true, asks the user before doing so.
|
|
[void] CleanDBTemp([bool] $Confirm) {
|
|
if ($Confirm -and !$this.GetYesNo("Ok to remove temporary databases/workfiles for this session")) {
|
|
$this.Output("Retaining all temporary work files.")
|
|
$this.WriteLog("Exit - Retain temp files.")
|
|
return
|
|
}
|
|
|
|
$DBTemp = Join-Path $this.PlexDBDir -ChildPath "dbtmp"
|
|
if ($this.DirExists($DBTemp)) {
|
|
try {
|
|
Remove-Item $DBTemp -Recurse -Force -EA Stop
|
|
$this.Output("Deleted all temporary work files.")
|
|
$this.WriteLog("Exit - Deleted temp files.")
|
|
} catch {
|
|
$Err = $Error -join "`n"
|
|
$this.OutputWarn("Failed to remove temporary directory: $Err")
|
|
$this.WriteLog("Exit - Failed to remove temporary files: $Err")
|
|
$Error.Clear()
|
|
}
|
|
}
|
|
}
|
|
|
|
### Miscellaneous Helpers ###
|
|
|
|
# Return whether PMS is running
|
|
[bool] PMSRunning() {
|
|
return $null -ne $this.GetPMS()
|
|
}
|
|
|
|
# Retrieve the PMS process, if running
|
|
[System.Diagnostics.Process] GetPMS() {
|
|
return Get-Process -EA Ignore -Name "Plex Media Server"
|
|
}
|
|
|
|
# Ask the user a yes or no question, continuing to prompt them until
|
|
# their input starts with either a 'Y' or 'N'
|
|
[bool] GetYesNo([string] $Prompt) {
|
|
$Response = (Read-Host "$Prompt [Y/N]? ").ToLower()
|
|
$Ch = $Response.Substring(0, [Math]::Min($Response.Length, 1))
|
|
while (($Ch -ne "y") -and ($Ch -ne "n")) {
|
|
Write-Host "Invalid input, please enter [Y]es or [N]o"
|
|
$Response = (Read-Host "$Prompt [Y/N]? ").ToLower()
|
|
$Ch = $Response.Substring(0, [Math]::Min($Response.Length, 1))
|
|
}
|
|
|
|
return $Ch -eq "y"
|
|
}
|
|
}
|
|
|
|
# Contains miscellaneous options/state over the course of a session.
|
|
class PlexDBRepairOptions {
|
|
[bool] $Scripted # Whether we're running in scripted or interactive mode
|
|
[bool] $ShowMenu # Whether to show the menu after each command executes
|
|
[bool] $IgnoreErrors # Whether to honor or ignore constraint errors on import
|
|
[bool] $CanIgnore # Some errors can't be ignored (e.g. integrity_check)
|
|
[int32] $CacheAge # The date cutoff for pruning PhotoTranscoder cached images
|
|
|
|
PlexDBRepairOptions() {
|
|
$this.CacheAge = 30
|
|
$this.ShowMenu = $true
|
|
$this.Scripted = $false
|
|
$this.IgnoreErrors = $false
|
|
$this.CanIgnore = $true
|
|
}
|
|
}
|
|
|
|
# Contains relevant data about a PhotoTranscoder `prune` attempt
|
|
class CleanCacheResult {
|
|
[int32] $TotalFiles # Total number of PhotoTranscoder files
|
|
[int32] $PrunableFiles # Total number of files that are older than the cutoff
|
|
[string] $SpaceSavings # Friendly string of (potential) space savings
|
|
|
|
CleanCacheResult([int32] $TotalFiles, [int32] $PrunableFiles, [int32] $PrunableBytes) {
|
|
$this.TotalFiles = $TotalFiles
|
|
$this.PrunableFiles = $PrunableFiles
|
|
$this.SpaceSavings = "$($PrunableBytes) bytes"
|
|
|
|
if ($PrunableBytes -gt 1GB) {
|
|
$this.SpaceSavings = "$([math]::round($PrunableBytes / 1GB, 2)) GiB";
|
|
} elseif ($PrunableBytes -gt 1MB) {
|
|
$this.SpaceSavings = "$([math]::round($PrunableBytes / 1MB, 2)) MiB";
|
|
} elseif ($PrunableBytes -gt 1KB) {
|
|
$this.SpaceSavings = "$([math]::round($PrunableBytes / 1KB, 2)) KiB";
|
|
}
|
|
}
|
|
}
|
|
|
|
# Ensure the console can handle utf-8, as the export process pipes utf-8 data from the console to our temporary sql file.
|
|
$InputEncodingSave = [console]::InputEncoding
|
|
$OutputEncodingSave = [console]::OutputEncoding
|
|
[console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding
|
|
|
|
[void]([PlexDBRepair]::new($args, $PlexDBRepairVersion))
|
|
|
|
[console]::OutputEncoding = $OutputEncodingSave
|
|
[console]::InputEncoding = $InputEncodingSave
|