├── .gitignore ├── FAQs.md ├── GETTING STARTED.md ├── LICENSE ├── PlexBackup.ps1 ├── PlexBackup.ps1.SAMPLE.json ├── README.md └── SCHEDULED PLEX BACKUP.md /.gitignore: -------------------------------------------------------------------------------- 1 | PlexBackup.ps1.json 2 | PlexBackup.ps1.xml 3 | -------------------------------------------------------------------------------- /FAQs.md: -------------------------------------------------------------------------------- 1 | # Frequently asked questions (FAQs) 2 | 3 | ## Does PlexBackup.ps1 support incremental backup? 4 | 5 | The point of incremental backup is to make it work faster than normal backup. Unfortunately, to apply an incremental update to a compressed archive, an archiver tool would first need to decompress the archive, make changes, and then compress it again, which in most cases, will be slower than normal backup. So what's the point? If you are using a `Robocopy` backup, you can apply incremental changes if you run it with the `Continue` switch (keep in mind that that the `Continue` switch works differently for compressed backups). 6 | 7 | ## Can PlexBackup.ps1 use WinRAR instead of the default Windows archiver or 7-zip? 8 | 9 | No, adding another archiving program with its own set of command-line options and behavior would complicate the code without providing much in return. So far, 7-zip is free and works really well, so there is no reason to complicate things. 10 | *** 11 | 12 | _Submit your questions and the answers will be posted here._ 13 | -------------------------------------------------------------------------------- /GETTING STARTED.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | To start your first Plex backup, follow these steps. 3 | 4 | ## Basic setup 5 | 6 | For minimal backup functionality, do the following: 7 | 8 | ### Log in 9 | 10 | Log on to Windows under the same account your Plex Media Server runs. 11 | 12 | ### Download files 13 | 14 | To download the Plex backup files, either clone this repository or simply copy the following files: 15 | 16 | - [PlexBackup.ps1](PlexBackup.ps1) 17 | - [PlexBackup.ps1.SAMPLE.json](PlexBackup.ps1.SAMPLE.json) 18 | 19 | If you choose to copy the files manually, make sure that your procedure does not corrupt characters (it can happen with special characters, like dashes, which may be converted to non-ASCII characters during the copy operation). 20 | 21 | ### Rename sample config file 22 | 23 | Rename `PlexBackup.ps1.SAMPLE.json` file to `PlexBackup.ps1.json` and make sure it's located in the same folder as `PlexBackup.ps1`. 24 | 25 | ### Pick a backup folder 26 | 27 | By default, PlexBackup.ps1 creates a backup folder in the same directory from which the script is running. Make sure you are not backing up Plex app data to the same drive (you should save backed up data on a separate local drive, if your computer has more than one, an external drive, such as a [NAS](https://en.wikipedia.org/wiki/Network-attached_storage) share, or an external USB hard drive). The backup drive must have enough space. 28 | 29 | ### Update config file settings 30 | 31 | Open the `PlexBackup.ps1.json` file in a text editor, such as _Notepad_, and verify the backup settings. At the very least, set the location of the backup root folder using the `value` property of the `BackupRootDir` element. Remember to escape backslash characters (`\`) in the path with another backslash character, e.g. if your backup root folder points to the `\\MYNAS\Backup` share, it must be entered as: 32 | 33 | ```JavaScript 34 | "BackupRootDir": { 35 | "_meta": { 36 | "default": "$PSScriptRoot" 37 | }, 38 | "value": "\\\\MYNAS\\Backups" 39 | }, 40 | ``` 41 | 42 | Save the config file. 43 | 44 | ### Make sure Plex Media Server is running 45 | 46 | When performing a backup, we want to make sure that Plex installation is not corrupted (you would not want to make a backup of a non-functioning Plex instance, right?), so Plex Media Server must be running. 47 | 48 | ### Launch PowerShell 49 | 50 | Launch PowerShell _as administrator_ (the backup script performs a few operations, such as stopping and starting services, that require elevated privileges). 51 | 52 | ### Run backup script 53 | 54 | In the PowerShell prompt, switch to the Plex backup script folder and enter the following command: 55 | 56 | ```PowerShell 57 | .\PlexBackup.ps1 58 | ``` 59 | 60 | If PowerShell does not allow you to launch scripts, [adjust the execution policy settings](README.md#script-execution) (you may also need to make a non-destructive change to the script to fool Windows into thinking that it is a local script and not a script downloaded from the Internet). 61 | 62 | If the script prompts you to [update the NuGet version](https://docs.microsoft.com/en-us/powershell/gallery/how-to/getting-support/bootstrapping-nuget), type in `Y` to do so. 63 | 64 | Once the script runs, monitor the output. If an errors occurs, try to understand the error message and correct the problem. If you get stuck, submit an [issue](../../issues). 65 | 66 | ## Advanced setup 67 | 68 | Once you verify that Plex backup is working, you can adjust the settings to better serve your needs. You can set it up to receive [email notifications](README.md#email-notification), have it send you a log file upon completion, run it as a [scheduled task](SCHEDULED%20PLEX%20BACKUP.md), and do more. 69 | 70 | ## Restore 71 | 72 | To restore Plex application data from a backup, make sure that you have a running Plex instance (e.g. a brand new Plex installation). Verify the config file (`PlexBackup.ps1.json`) settings (in a typical case, you do not need to make any changes to the config file since the script will pick up the latest backup snapshot from the backup root folder). Execute the following command: 73 | 74 | ```PowerShell 75 | .\PlexBackup.ps1 -Restore 76 | ``` 77 | 78 | You may want to verify that the restore operation is successful before starting Plex Media Server, in which case, run the script with the `Shutdown` switch: 79 | 80 | ```PowerShell 81 | .\PlexBackup.ps1 -Restore -Shutdown 82 | ``` 83 | 84 | Enjoy! 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Alek Davis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PlexBackup.ps1: -------------------------------------------------------------------------------- 1 | #------------------------------[ HELP INFO ]------------------------------- 2 | 3 | <# 4 | .SYNOPSIS 5 | Backs up and restores Plex application data files and registry keys on a Windows system. 6 | 7 | .DESCRIPTION 8 | The script can run in these modes: 9 | 10 | - Backup : A default mode to initiate a new backup. 11 | - Continue : Continues from where a previous backup stopped. 12 | - Restore : Restores Plex app data from a backup. 13 | 14 | The script backs up the contents of the 'Plex Media Server' folder (app data folder) with the exception of some top-level, non-essential folders. You can customize the list of folders that do not need to be backed up. By default, the following top-level app data folders are not backed up: 15 | 16 | - Diagnostics 17 | - Crash Reports 18 | - Updates 19 | - Logs 20 | 21 | The backup process compresses the contents Plex app data folders to the ZIP files (with the exception of folders containing subfolders and files with really long paths). For efficiency reasons, the script first compresses the data in a temporary folder and then copies the compressed (ZIP) files to the backup destination folder. You can compress data to the backup destination folder directly (bypassing the saving to the temp folder step) by setting the value of the $TempZipFileDir variable to null or empty string. Folders holding subfolders and files with very long paths get special treatment: instead of compressing them, before performing the backup, the script moves them to the backup folder as-is, and after the backup, it copies them to their original locations. Alternatively, backup can create a mirror of the essential app data folder via the Robocopy command (to use this option, set the -Robocopy switch). 22 | 23 | In addition to backing up Plex app data, the script also backs up the contents of the Plex Windows Registry key. 24 | 25 | The backup is created in the specified backup folder under a subfolder which name reflects the script start time. It deletes the old backup folders (you can specify the number of old backup folders to keep). 26 | 27 | If the backup process does not complete due to error, you can run backup in the Continue mode (using the '-Mode Continue' command-line switch) and it will resume from where it left off. By default, the script picks up the most recent backup folder, but you can specify the backup folder using the -BackupDirPath command-line switch. 28 | 29 | When restoring Plex application data from a backup, the script expects the backup folder structure to be the same as the one it creates when it runs in the backup mode. By default, it use the backup folder with the name reflecting the most recent timestamp, but you can specify an alternative backup folder. 30 | 31 | Before creating backup or restoring data from a backup, the script stops all running Plex services and the Plex Media Server process (it restarts them once the operation is completed). You can force the script to not start the Plex Media Server process via the -Shutdown switch. 32 | 33 | To override the default script settings, modify the values of script parameters and global variables inline or specify them in a config file. 34 | 35 | The script can send an email notification on the operation completion. 36 | 37 | The config file must use the JSON format. Not every script variable can be specified in the config file (for the list of overridable variables, see the sample config file). Only non-null values from config file will be used. The default config file is named after the running script with the '.json' extension, such as: PlexBackup.ps1.json. You can specify a custom config file via the -ConfigFile command-line switch. Config file is optional. All config values in the config file are optional. The non-null config file settings override the default and command-line paramateres, e.g. if the command-line -Mode switch is set to 'Backup' and the corresponding element in the config file is set to 'Restore' then the script will run in the Restore mode. 38 | 39 | On success, the script will set the value of the $LASTEXITCODE variable to 0; on error, it will be greater than zero. 40 | 41 | This script must run as an administrator. 42 | 43 | The execution policy must allow running scripts. To check execution policy, run the following command: 44 | 45 | Get-ExecutionPolicy 46 | 47 | If the execution policy does not allow running scripts, do the following: 48 | 49 | (1) Start Windows PowerShell with the "Run as Administrator" option. 50 | (2) Run the following command: 51 | 52 | Set-ExecutionPolicy RemoteSigned 53 | 54 | This will allow running unsigned scripts that you write on your local computer and signed scripts from Internet. 55 | 56 | Alternatively, you may want to run the script as: 57 | 58 | start powershell.exe -noprofile -executionpolicy bypass -file .\PlexBackup.ps1 -ConfigFile .\PlexBackup.ps1.json 59 | 60 | See also 'Running Scripts' at Microsoft TechNet Library: 61 | 62 | https://docs.microsoft.com/en-us/previous-versions//bb613481(v=vs.85) 63 | 64 | .PARAMETER Mode 65 | Specifies the mode of operation: 66 | 67 | - Backup (default) 68 | - Continue 69 | - Restore 70 | 71 | .PARAMETER Backup 72 | Shortcut for '-Mode Backup'. 73 | 74 | .PARAMETER Continue 75 | Shortcut for '-Mode Continue'. 76 | 77 | .PARAMETER Restore 78 | Shortcut for '-Mode Restore'. 79 | 80 | .PARAMETER Type 81 | Specifies the non-default type of backup method: 82 | 83 | - 7zip 84 | - Robocopy 85 | 86 | By default, the script will use the built-in compression. 87 | 88 | .PARAMETER SevenZip 89 | Shortcut for '-Type 7zip' 90 | 91 | .PARAMETER Robocopy 92 | Shortcut for '-Type Robocopy'. 93 | 94 | .PARAMETER ModuleDir 95 | Optional path to directory holding the modules used by this script. This can be useful if the script runs on the system with no or restricted access to the Internet. By default, the module path will point to the 'Modules' folder in the script's folder. 96 | 97 | .PARAMETER ConfigFile 98 | Path to the optional custom config file. The default config file is named after the script with the '.json' extension, such as 'PlexBackup.ps1.json'. 99 | 100 | .PARAMETER PlexAppDataDir 101 | Location of the Plex Media Server application data folder. 102 | 103 | .PARAMETER BackupRootDir 104 | Path to the root backup folder holding timestamped backup subfolders. If not specified, the script folder will be used. 105 | 106 | .PARAMETER BackupDirPath 107 | When running the script in the 'Restore' mode, holds path to the backup folder (by default, the subfolder with the most recent timestamp in the name located in the backup root folder will be used). 108 | 109 | .PARAMETER TempDir 110 | Temp folder used to stage the archiving job (use local drive for efficiency). To bypass the staging step, set this parameter to null or empty string. 111 | 112 | .PARAMETER WakeUpDir 113 | Optional path to a remote share that may need to be woken up before starting Plex Media Server. 114 | 115 | .PARAMETER ArchiverPath 116 | Defines the path to the 7-zip command line tool (7z.exe) which is required when running the script with the '-Type 7zip' or '-SevenZip' switch. Default: $env:ProgramFiles\7-Zip\7z.exe. 117 | 118 | .PARAMETER Quiet 119 | Set this switch to suppress log entries sent to a console. 120 | 121 | .PARAMETER LogLevel 122 | Specifies the log level of the output: 123 | 124 | - None 125 | - Error 126 | - Warning 127 | - Info 128 | - Debug 129 | 130 | .PARAMETER Log 131 | When set to true, informational messages will be written to a log file. The default log file will be created in the backup folder and will be named after this script with the '.log' extension, such as 'PlexBackup.ps1.log'. 132 | 133 | .PARAMETER LogFile 134 | Use this switch to specify a custom log file location. When this parameter is set to a non-null and non-empty value, the '-Log' switch can be omitted. 135 | 136 | .PARAMETER ErrorLog 137 | When set to true, error messages will be written to an error log file. The default error log file will be created in the backup folder and will be named after this script with the '.err.log' extension, such as 'PlexBackup.ps1.err.log'. 138 | 139 | .PARAMETER ErrorLogFile 140 | Use this switch to specify a custom error log file location. When this parameter is set to a non-null and non-empty value, the '-ErrorLog' switch can be omitted. 141 | 142 | .PARAMETER Keep 143 | Number of old backups to keep: 144 | 145 | 0 - retain all previously created backups, 146 | 1 - latest backup only, 147 | 2 - latest and one before it, 148 | 3 - latest and two before it, 149 | 150 | and so on. 151 | 152 | .PARAMETER Retries 153 | The number of retries on failed copy operations (corresponds to the Robocopy /R switch). 154 | 155 | .PARAMETER RetryWaitSec 156 | Specifies the wait time between retries in seconds (corresponds to the Robocopy /W switch). 157 | 158 | .PARAMETER RawOutput 159 | Set this switch to display raw output from the external commands, such as Robocopy or 7-zip. 160 | 161 | .PARAMETER Inactive 162 | When set, allows the script to continue if Plex Media Server is not running. 163 | 164 | .PARAMETER NoRestart 165 | Set this switch to not start the Plex Media Server process at the end of the operation (could be handy for restores, so you can double check that all is good before launching Plex media Server). 166 | 167 | .PARAMETER NoSingleton 168 | Set this switch to ignore check for multiple script instances running concurrently. 169 | 170 | .PARAMETER NoVersion 171 | Forces restore to ignore version mismatch between the current version of Plex Media Server and the version of Plex Media Server active during backup. 172 | 173 | .PARAMETER NoLogo 174 | Specify this command-line switch to not print version and copyright info. 175 | 176 | .PARAMETER Test 177 | When turned on, the script will not generate backup files or restore Plex app data from the backup files. 178 | 179 | .PARAMETER SendMail 180 | Indicates in which case the script must send an email notification about the result of the operation: 181 | 182 | - Never (default) 183 | - Always 184 | - OnError (for any operation) 185 | - OnSuccess (for any operation) 186 | - OnBackup (for both the Backup and Continue modes on either error or success) 187 | - OnBackupError 188 | - OnBackupSuccess 189 | - OnRestore (on either error or success) 190 | - OnRestoreError 191 | - OnRestoreSuccess 192 | 193 | .PARAMETER SmtpServer 194 | Defines the SMTP server host. If not specified, the notification will not be sent. 195 | 196 | .PARAMETER Port 197 | Specifies an alternative port on the SMTP server. Default: 0 (zero, i.e. default port 25 will be used). 198 | 199 | .PARAMETER From 200 | Specifies the email address when email notification sender. If this value is not provided, the username from the credentails saved in the credentials file or entered at the credentials prompt will be used. If the From address cannot be determined, the notification will not be sent. 201 | 202 | .PARAMETER To 203 | Specifies the email address of the email recipient. If this value is not provided, the addressed defined in the To parameter will be used. 204 | 205 | .PARAMETER NoSsl 206 | Tells the script not to use the Secure Sockets Layer (SSL) protocol when connecting to the SMTP server. By default, SSL is used. 207 | 208 | .PARAMETER CredentialFile 209 | Path to the file holding username and encrypted password of the account that has permission to send mail via the SMTP server. You can generate the file via the following PowerShell command: 210 | 211 | Get-Credential | Export-CliXml -Path "PathToFile.xml" 212 | 213 | The default log file will be created in the backup folder and will be named after this script with the '.xml' extension, such as 'PlexBackup.ps1.xml'. 214 | 215 | .PARAMETER SaveCredential 216 | When set, the SMTP credentials will be saved in a file (encrypted with user- and machine-specific key) for future use. 217 | 218 | .PARAMETER Anonymous 219 | Tells the script to not use credentials when sending email notifications. 220 | 221 | .PARAMETER SendLogFile 222 | Indicates in which case the script must send an attachment along with th email notification: 223 | 224 | - Never (default) 225 | - Always 226 | - OnError 227 | - OnSuccess 228 | 229 | .PARAMETER Logoff 230 | Specify this command-line switch to log off all user accounts (except the running one) before starting Plex Media Server. This may help address issues with remote drive mappings under the wrong credentials. 231 | 232 | .PARAMETER Reboot 233 | Reboots the computer after a successful backup operation (ignored on restore). 234 | 235 | .PARAMETER ForceReboot 236 | Forces an immediate restart of the computer after a successfull backup operation (ignored on restore). 237 | 238 | .PARAMETER Machine 239 | Intends to overcome an infrequent error "87: The parameter is incorrect" returned by the runas command attempting to relaunch Plex Media Server: 240 | 241 | - x86 242 | - amd64 243 | 244 | Keep in mind that these refer to the architecture of the Plex Media Server executable, not to the computer on which it runs, so for the 32-bit version of the server, use 'x86' and for the 64-bit version, use 'amd64'. Do not use this flag if you do not get error 87. 245 | 246 | .NOTES 247 | Version : 2.1.10 248 | Author : Alek Davis 249 | Created on : 2025-04-28 250 | License : MIT License 251 | LicenseLink: https://github.com/alekdavis/PlexBackup/blob/master/LICENSE 252 | Copyright : (c) 2025 Alek Davis 253 | 254 | .LINK 255 | https://github.com/alekdavis/PlexBackup 256 | 257 | .INPUTS 258 | None. 259 | 260 | .OUTPUTS 261 | None. 262 | 263 | .EXAMPLE 264 | PlexBackup.ps1 265 | Backs up compressed Plex application data to the default backup location. 266 | 267 | .EXAMPLE 268 | PlexBackup.ps1 -Robocopy 269 | Backs up Plex application data to the default backup location using the Robocopy command instead of the file and folder compression. 270 | 271 | .EXAMPLE 272 | PlexBackup.ps1 -SevenZip 273 | Backs up Plex application data to the default backup location using the 7-zip command-line tool (7z.exe). 7-zip command-line tool must be installed and the script must know its path. 274 | 275 | .EXAMPLE 276 | PlexBackup.ps1 -BackupRootDir "\\MYNAS\Backup\Plex" 277 | Backs up Plex application data to the specified backup location on a network share. 278 | 279 | .EXAMPLE 280 | PlexBackup.ps1 -Continue 281 | Continues a previous backup process from where it left off. 282 | 283 | .EXAMPLE 284 | PlexBackup.ps1 -Restore 285 | Restores Plex application data from the latest backup in the default folder. 286 | 287 | .EXAMPLE 288 | PlexBackup.ps1 -Restore -Robocopy 289 | Restores Plex application data from the latest backup in the default folder created using the Robocopy command. 290 | 291 | .EXAMPLE 292 | PlexBackup.ps1 -Mode Restore -BackupDirPath "\\MYNAS\PlexBackup\20190101183015" Restores Plex application data from a backup in the specified remote folder. 293 | 294 | .EXAMPLE 295 | PlexBackup.ps1 -SendMail Always -Prompt -Save -SendLogFile OnError -SmtpServer smtp.gmail.com -Port 587 296 | Runs a backup job and sends an email notification over an SSL channel. If the backup operation fails, the log file will be attached to the email message. The sender's and the recipient's email addresses will determined from the username of the credential object. The credential object will be set either from the credential file or, if the file does not exist, via a user prompt (in the latter case, the credential object will be saved in the credential file with password encrypted using a user- and computer-specific key). 297 | 298 | .EXAMPLE 299 | Get-Help .\PlexBackup.ps1 300 | View help information. 301 | #> 302 | 303 | #------------------------------[ IMPORTANT ]------------------------------- 304 | 305 | <# 306 | PLEASE MAKE SURE THAT THE SCRIPT STARTS WITH THE COMMENT HEADER ABOVE AND 307 | THE HEADER IS FOLLOWED BY AT LEAST ONE BLANK LINE; OTHERWISE, GET-HELP AND 308 | GETVERSION COMMANDS WILL NOT WORK. 309 | #> 310 | 311 | #------------------------[ RUN-TIME REQUIREMENTS ]------------------------- 312 | 313 | #Requires -Version 4.0 314 | #Requires -RunAsAdministrator 315 | 316 | #------------------------[ COMMAND-LINE SWITCHES ]------------------------- 317 | 318 | # Script command-line arguments (see descriptions in the .PARAMETER comments 319 | # above). These parameters can also be specified in the accompanying 320 | # configuration (.json) file. 321 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute( ` 322 | 'PSAvoidUsingPlainTextForPassword', 'CredentialFile', ` 323 | Justification='No need for SecureString, since it holds path, not secret.')] 324 | [CmdletBinding(DefaultParameterSetName="default")] 325 | param ( 326 | [Parameter(ParameterSetName="Mode")] 327 | [Parameter(Mandatory, ParameterSetName="ModeType")] 328 | [Parameter(Mandatory, ParameterSetName="ModeSevenZip")] 329 | [Parameter(Mandatory, ParameterSetName="ModeRobocopy")] 330 | [ValidateSet("", "Backup", "Continue", "Restore")] 331 | [string] 332 | $Mode = "", 333 | 334 | [Parameter(ParameterSetName="Backup")] 335 | [Parameter(Mandatory, ParameterSetName="BackupType")] 336 | [Parameter(Mandatory, ParameterSetName="BackupSevenZip")] 337 | [Parameter(Mandatory, ParameterSetName="BackupRobocopy")] 338 | [switch] 339 | $Backup, 340 | 341 | [Parameter(ParameterSetName="Continue")] 342 | [Parameter(Mandatory, ParameterSetName="ContinueType")] 343 | [Parameter(Mandatory, ParameterSetName="ContinueSevenZip")] 344 | [Parameter(Mandatory, ParameterSetName="ContinueRobocopy")] 345 | [switch] 346 | $Continue, 347 | 348 | [Parameter(ParameterSetName="Restore")] 349 | [Parameter(Mandatory, ParameterSetName="RestoreType")] 350 | [Parameter(Mandatory, ParameterSetName="RestoreSevenZip")] 351 | [Parameter(Mandatory, ParameterSetName="RestoreRobocopy")] 352 | [switch] 353 | $Restore, 354 | 355 | [Parameter(ParameterSetName="Type")] 356 | [Parameter(Mandatory, ParameterSetName="ModeType")] 357 | [Parameter(Mandatory, ParameterSetName="BackupType")] 358 | [Parameter(Mandatory, ParameterSetName="ContinueType")] 359 | [Parameter(Mandatory, ParameterSetName="RestoreType")] 360 | [ValidateSet("", "7zip", "Robocopy")] 361 | [string] 362 | $Type = "", 363 | 364 | [Parameter(ParameterSetName="SevenZip")] 365 | [Parameter(Mandatory, ParameterSetName="ModeSevenZip")] 366 | [Parameter(Mandatory, ParameterSetName="BackupSevenZip")] 367 | [Parameter(Mandatory, ParameterSetName="ContinueSevenZip")] 368 | [Parameter(Mandatory, ParameterSetName="RestoreSevenZip")] 369 | [switch] 370 | $SevenZip, 371 | 372 | [Parameter(ParameterSetName="Robocopy")] 373 | [Parameter(Mandatory, ParameterSetName="ModeRobocopy")] 374 | [Parameter(Mandatory, ParameterSetName="BackupRobocopy")] 375 | [Parameter(Mandatory, ParameterSetName="ContinueRobocopy")] 376 | [Parameter(Mandatory, ParameterSetName="RestoreRobocopy")] 377 | [switch] 378 | $Robocopy, 379 | 380 | [string] 381 | $ModuleDir = "$PSScriptRoot\Modules", 382 | 383 | [Alias("Config")] 384 | [string] 385 | $ConfigFile, 386 | 387 | [string] 388 | $PlexAppDataDir = "$env:LOCALAPPDATA\Plex Media Server", 389 | 390 | [string] 391 | $BackupRootDir = $PSScriptRoot, 392 | 393 | [Alias("BackupDirPath")] 394 | [string] 395 | $BackupDir = $null, 396 | 397 | [Alias("TempZipFileDir")] 398 | [AllowEmptyString()] 399 | [string] 400 | $TempDir = $env:TEMP, 401 | 402 | [string] 403 | $WakeUpDir = $null, 404 | 405 | [string] 406 | $ArchiverPath = "$env:ProgramFiles\7-Zip\7z.exe", 407 | 408 | [Alias("Q")] 409 | [switch] 410 | $Quiet, 411 | 412 | [ValidateSet("None", "Error", "Warning", "Info", "Debug")] 413 | [string] 414 | $LogLevel = "Info", 415 | 416 | [Alias("L")] 417 | [switch] 418 | $Log, 419 | 420 | [string] 421 | $LogFile, 422 | 423 | [switch] 424 | $ErrorLog, 425 | 426 | [string] 427 | $ErrorLogFile, 428 | 429 | [ValidateRange(0,[int]::MaxValue)] 430 | [int] 431 | $Keep = 3, 432 | 433 | [ValidateRange(0,[int]::MaxValue)] 434 | [int] 435 | $Retries = 5, 436 | 437 | [ValidateRange(0,[int]::MaxValue)] 438 | [int] 439 | $RetryWaitSec = 10, 440 | 441 | [switch] 442 | $RawOutput, 443 | 444 | [switch] 445 | $Inactive, 446 | 447 | [Alias("Shutdown")] 448 | [switch] 449 | $NoRestart, 450 | 451 | [switch] 452 | $NoSingleton, 453 | 454 | [Alias("Force")] 455 | [Alias("NoVersionCheck")] 456 | [switch] 457 | $NoVersion, 458 | 459 | [switch] 460 | $NoLogo, 461 | 462 | [switch] 463 | $Test, 464 | 465 | [ValidateSet( 466 | "Never", "Always", "OnError", "OnSuccess", 467 | "OnBackup", "OnBackupError", "OnBackupSuccess", 468 | "OnRestore", "OnRestoreError", "OnRestoreSuccess")] 469 | [string] 470 | $SendMail = "Never", 471 | 472 | [string] 473 | $SmtpServer, 474 | 475 | [ValidateRange(0,[int]::MaxValue)] 476 | [int] 477 | $Port = 0, 478 | 479 | [string] 480 | $From = $null, 481 | 482 | [string] 483 | $To, 484 | 485 | [switch] 486 | $NoSsl, 487 | 488 | [Alias("Credential")] 489 | [string] 490 | $CredentialFile = $null, 491 | 492 | [switch] 493 | $SaveCredential, 494 | 495 | [switch] 496 | $Anonymous, 497 | 498 | [ValidateSet("Never", "OnError", "OnSuccess", "Always")] 499 | [string] 500 | $SendLogFile = "Never", 501 | 502 | [switch] 503 | $Logoff, 504 | 505 | [switch] 506 | $Reboot, 507 | 508 | [switch] 509 | $ForceReboot, 510 | 511 | [ValidateSet("x86", "amd64")] 512 | [string] 513 | $Machine 514 | ) 515 | #---------------[ VARIABLES CONFIGURABLE VIA CONFIG FILE ]----------------- 516 | 517 | # The following Plex application folders do not need to be backed up. 518 | $ExcludeDirs = @( 519 | "Diagnostics", 520 | "Crash Reports", 521 | "Updates", 522 | "Logs" 523 | ) 524 | 525 | # The following file types do not need to be backed up: 526 | # *.bif - thumbnail previews 527 | # Transcode - cache subfolder used for transcoding and syncs (could be huge) 528 | $ExcludeFiles = @( 529 | "*.bif", 530 | "Transcode" 531 | ) 532 | 533 | # Subfolders that cannot be archived because the path may be too long. 534 | # Long (over 260 characters) paths cause Compress-Archive to fail, 535 | # so before running the archival steps, we will move these folders to 536 | # the backup directory, and copy it back once the archival step completes. 537 | # On restore, we'll copy these folders from the backup directory to the 538 | # Plex app data folder. 539 | $SpecialDirs = @( 540 | "Plug-in Support\Data\com.plexapp.system\DataItems\Deactivated" 541 | ) 542 | 543 | # Regular expression used to find display names of the Plex Windows service(s). 544 | $PlexServiceName = "^Plex" 545 | 546 | # Name of the Plex Media Server executable file. 547 | $PlexServerFileName = "Plex Media Server.exe" 548 | 549 | # If Plex Media Server is not running, define path to the executable here. 550 | $PlexServerPath = $null 551 | 552 | # 7-zip command-line option for compression. 553 | $ArchiverOptionsCompress = 554 | @( 555 | $null 556 | ) 557 | 558 | # 7-zip command-line option for decompression. 559 | $ArchiverOptionsExpand = 560 | @( 561 | $null 562 | ) 563 | 564 | #-------------------------[ MODULE DEPENDENCIES ]-------------------------- 565 | 566 | # Module to get script version info: 567 | # https://www.powershellgallery.com/packages/ScriptVersion 568 | 569 | # Module to initialize script parameters and variables from a config file: 570 | # https://www.powershellgallery.com/packages/ConfigFile 571 | 572 | # Module implementing logging to file and console routines: 573 | # https://www.powershellgallery.com/packages/StreamLogging 574 | 575 | # Module responsible for making sure only one instance of the script is running: 576 | # https://www.powershellgallery.com/packages/SingleInstance 577 | 578 | $MODULE_ScriptVersion = "ScriptVersion" 579 | $MODULE_ConfigFile = "ConfigFile" 580 | $MODULE_StreamLogging = "StreamLogging" 581 | $MODULE_SingleInstance = "SingleInstance" 582 | 583 | $MODULES = @( 584 | $MODULE_ScriptVersion, 585 | $MODULE_ConfigFile, 586 | "$MODULE_StreamLogging|1.2.1", 587 | $MODULE_SingleInstance 588 | ) 589 | 590 | #------------------------------[ CONSTANTS ]------------------------------- 591 | 592 | # Mutex name (to enforce single instance). 593 | $MUTEX_NAME = $PSCommandPath.Replace("\", "/") 594 | 595 | # Plex registry key path. 596 | $PLEX_REG_KEYS = @( 597 | "HKCU:\Software\Plex, Inc.\Plex Media Server", 598 | "HKU:\.DEFAULT\Software\Plex, Inc.\Plex Media Server" 599 | ) 600 | 601 | # Default path of the Plex Media Server.exe. 602 | $DEFAULT_PLEX_SERVER_EXE_PATH = "${env:ProgramFiles(x86)}\Plex\Plex Media Server\Plex Media Server.exe" 603 | 604 | # File extensions. 605 | $FILE_EXT_ZIP = ".zip" 606 | $FILE_EXT_7ZIP = ".7z" 607 | $FILE_EXT_REG = ".reg" 608 | $FILE_EXT_CRED = ".xml" 609 | 610 | # File names. 611 | $VERSION_FILE_NAME = "Version.txt" 612 | 613 | # Backup mode types. 614 | $MODE_BACKUP = "Backup" 615 | $MODE_CONTINUE = "Continue" 616 | $MODE_RESTORE = "Restore" 617 | 618 | # Backup types. 619 | $TYPE_ROBOCOPY = "Robocopy" 620 | $TYPE_7ZIP = "7zip" 621 | 622 | # Backup folder format: YYYYMMDDhhmmss 623 | $REGEX_BACKUPDIRNAMEFORMAT = "^\d{14}$" 624 | 625 | # Format of the backup folder name (so it can be easily sortable). 626 | $BACKUP_DIRNAMEFORMAT = "yyyyMMddHHmmss" 627 | 628 | # Subfolders in the backup directory. 629 | $SUBDIR_FILES = "1" 630 | $SUBDIR_FOLDERS = "2" 631 | $SUBDIR_REGISTRY = "3" 632 | $SUBDIR_SPECIAL = "4" 633 | 634 | # Name of the common backup files. 635 | $BACKUP_FILENAME = "Plex" 636 | 637 | # Send mail settings. 638 | $SEND_MAIL_NEVER = "Never" 639 | $SEND_MAIL_ALWAYS = "Always" 640 | $SEND_MAIL_ERROR = "OnError" 641 | $SEND_MAIL_SUCCESS = "OnSuccess" 642 | $SEND_MAIL_BACKUP = "OnBackup" 643 | $SEND_MAIL_RESTORE = "OnRestore" 644 | 645 | $SEND_LOGFILE_NEVER = "Never" 646 | $SEND_LOGFILE_ALWAYS = "Always" 647 | $SEND_LOGFILE_ERROR = "OnError" 648 | $SEND_LOGFILE_SUCCESS = "OnSuccess" 649 | 650 | # Set variables for email notification. 651 | $SUBJECT_ERROR = "Plex backup failed :-(" 652 | $SUBJECT_SUCCESS = "Plex backup completed :-)" 653 | 654 | #------------------------------[ EXIT CODES]------------------------------- 655 | 656 | $EXITCODE_SUCCESS = 0 # success 657 | $EXITCODE_ERROR = 1 # error 658 | 659 | #----------------------[ NON-CONFIGURABLE VARIABLES ]---------------------- 660 | 661 | # File extensions. 662 | $ZipFileExt = $FILE_EXT_ZIP 663 | $RegFileExt = $FILE_EXT_REG 664 | 665 | # Files and folders. 666 | [string]$BackupDirName = $null 667 | [string]$VersionFilePath = $null 668 | 669 | # Version info. 670 | [string]$PlexVersion = $null 671 | [string]$BackupVersion = $null 672 | 673 | # Save time for logging purposes. 674 | [DateTime]$StartTime = Get-Date 675 | [DateTime]$EndTime = $StartTime 676 | [string] $Duration = $null 677 | 678 | # Mail credentials object. 679 | [PSCredential]$Credential = $null 680 | 681 | # Error message set in case of error. 682 | [string]$ErrorResult = $null 683 | 684 | # Backup stats. 685 | [string]$ObjectCount = "UNKNOWN" 686 | [string]$BackupSize = "UNKNOWN" 687 | 688 | # The default exit code indicates error (we'll set it to success at the end). 689 | $ExitCode = $EXITCODE_ERROR 690 | 691 | #--------------------------[ STANDARD FUNCTIONS ]-------------------------- 692 | 693 | #-------------------------------------------------------------------------- 694 | # SetModulePath 695 | # Adds custom folders to the module path. 696 | function SetModulePath { 697 | [CmdletBinding()] 698 | param( 699 | ) 700 | WriteDebug "Entered SetModulePath." 701 | 702 | if ($Script:ModuleDir) { 703 | if ($env:PSModulePath -notmatch ";$") { 704 | $env:PSModulePath += ";" 705 | } 706 | 707 | $paths = $Script:ModuleDir -split ";" 708 | 709 | foreach ($path in $paths){ 710 | $path = $path.Trim(); 711 | 712 | if (-not ($env:PSModulePath.ToLower(). 713 | Contains(";$path;".ToLower()))) { 714 | 715 | $env:PSModulePath += "$path;" 716 | } 717 | } 718 | } 719 | 720 | WriteDebug "Exiting SetModulePath." 721 | } 722 | 723 | #-------------------------------------------------------------------------- 724 | # GetModuleVersion 725 | # Returns version string for the specified module using format: 726 | # major.minor.build. 727 | function GetModuleVersion { 728 | [CmdletBinding()] 729 | param( 730 | [PSModuleInfo] 731 | $moduleInfo 732 | ) 733 | 734 | $major = $moduleInfo.Version.Major 735 | $minor = $moduleInfo.Version.Minor 736 | $build = $moduleInfo.Version.Build 737 | 738 | return "$major.$minor.$build" 739 | } 740 | 741 | #-------------------------------------------------------------------------- 742 | # GetVersionParts 743 | # Converts version string into three parts: major, minor, and build. 744 | function GetVersionParts { 745 | [CmdletBinding()] 746 | param( 747 | [string] 748 | $version 749 | ) 750 | 751 | $versionParts = $version.Split(".") 752 | 753 | $major = $versionParts[0] 754 | $minor = 0 755 | $build = 0 756 | 757 | if ($versionParts.Count -gt 1) { 758 | $minor = $versionParts[1] 759 | } 760 | 761 | if ($versionParts.Count -gt 2) { 762 | $build = $versionParts[2] 763 | } 764 | 765 | return $major, $minor, $build 766 | } 767 | 768 | #-------------------------------------------------------------------------- 769 | # CompareVersions 770 | # Compares two major, minor, and build parts of two version strings and 771 | # returns 0 is they are the same, -1 if source version is older, or 1 772 | # if source version is newer than target version. 773 | function CompareVersions { 774 | [CmdletBinding()] 775 | param( 776 | [string] 777 | $sourceVersion, 778 | 779 | [string] 780 | $targetVersion 781 | ) 782 | 783 | if ($sourceVersion -eq $targetVersion) { 784 | return 0 785 | } 786 | 787 | $sourceMajor, $sourceMinor, $sourceBuild = GetVersionParts $sourceVersion 788 | $targetMajor, $targetMinor, $targetBuild = GetVersionParts $targetVersion 789 | 790 | $source = @($sourceMajor, $sourceMinor, $sourceBuild) 791 | $target = @($targetMajor, $targetMinor, $targetBuild) 792 | 793 | for ($i = 0; $i -lt $source.Count; $i++) { 794 | $diff = $source[$i] - $target[$i] 795 | 796 | if ($diff -ne 0) { 797 | if ($diff -lt 0) { 798 | return -1 799 | } 800 | 801 | return 1 802 | } 803 | } 804 | 805 | return 0 806 | } 807 | 808 | #-------------------------------------------------------------------------- 809 | # IsSupportedVersion 810 | # Checks whether the specified version is within the min-max range. 811 | function IsSupportedVersion { 812 | [CmdletBinding()] 813 | param( 814 | [string] 815 | $version, 816 | 817 | [string] 818 | $minVersion, 819 | 820 | [string] 821 | $maxVersion 822 | ) 823 | 824 | if (!($minVersion) -and (!($maxVersion))) { 825 | return $true 826 | } 827 | 828 | if (($version -and $minVersion -and $maxVersion) -and 829 | ($minVersion -eq $maxVersion) -and 830 | ($version -eq $minVersion)) { 831 | return 0 832 | } 833 | 834 | if ($minVersion) { 835 | if ((CompareVersions $version $minVersion) -lt 0) { 836 | return $false 837 | } 838 | } 839 | 840 | if ($maxVersion) { 841 | if ((CompareVersions $version $maxVersion) -gt 0) { 842 | return $false 843 | } 844 | } 845 | 846 | return $true 847 | } 848 | 849 | #-------------------------------------------------------------------------- 850 | # LoadModules 851 | # Installs (if needed) and loads the specified PowerShell modules. 852 | function LoadModules { 853 | [CmdletBinding()] 854 | param( 855 | [string[]] 856 | $modules 857 | ) 858 | WriteDebug "Entered LoadModules." 859 | 860 | # Make sure we got the modules. 861 | if (!($modules) -or ($modules.Count -eq 0)) { 862 | return 863 | } 864 | 865 | $module = "" 866 | $cmdArgs = @{} 867 | 868 | try { 869 | foreach ($module in $modules) { 870 | Write-Verbose "Processing module '$module'." 871 | 872 | $moduleInfo = $module.Split("|:") 873 | 874 | $moduleName = $moduleInfo[0] 875 | $moduleVersion = "" 876 | $moduleMinVersion = "" 877 | $moduleMaxVersion = "" 878 | $cmdArgs.Clear() 879 | 880 | if ($moduleInfo.Count -gt 1) { 881 | $moduleMinVersion = $moduleInfo[1] 882 | 883 | if ($moduleMinVersion) { 884 | $cmdArgs["MinimumVersion"] = $moduleMinVersion 885 | } 886 | } 887 | 888 | if ($moduleInfo.Count -gt 2) { 889 | $moduleMaxVersion = $moduleInfo[2] 890 | 891 | if ($moduleMaxVersion) { 892 | $cmdArgs["MaximumVersion"] = $moduleMaxVersion 893 | } 894 | } 895 | 896 | Write-Verbose "Required module name: '$moduleName'." 897 | 898 | if ($moduleMinVersion) { 899 | Write-Verbose "Required module min version: '$moduleMinVersion'." 900 | } 901 | 902 | if ($moduleMaxVersion) { 903 | Write-Verbose "Required module max version: '$moduleMaxVersion'." 904 | } 905 | 906 | # Check if module is loaded into the process. 907 | $loadedModules = Get-Module -Name $moduleName 908 | 909 | $isLoaded = $false 910 | $isInstalled = $false 911 | 912 | if ($loadedModules) { 913 | Write-Verbose "Module '$moduleName' is loaded." 914 | 915 | # If version check is required, compare versions. 916 | if ($moduleMinVersion -or $moduleMaxVersion) { 917 | 918 | foreach ($loadedModule in $loadedModules) { 919 | $moduleVersion = GetModuleVersion $loadedModule 920 | 921 | Write-Verbose "Checking if loaded module '$moduleName' version '$moduleVersion' is supported." 922 | 923 | if (IsSupportedVersion $moduleVersion $moduleMinVersion $moduleMaxVersion) { 924 | Write-Verbose "Loaded module '$moduleName' version '$moduleVersion' is supported." 925 | $isLoaded = $true 926 | $isInstalled = $true 927 | break 928 | } else { 929 | Write-Verbose "Loaded module '$moduleName' version '$moduleVersion' is not supported." 930 | } 931 | } 932 | } else { 933 | $isLoaded = $true 934 | $isInstalled = $true 935 | } 936 | } 937 | 938 | # If module is not loaded or version is wrong. 939 | if (!$isLoaded) { 940 | Write-Verbose "Required module '$moduleName' is not loaded." 941 | 942 | # Check if module is locally available. 943 | $installedModules = Get-Module -ListAvailable -Name $moduleName 944 | 945 | $isInstalled = $false 946 | 947 | # If module is found, validate the version. 948 | if ($installedModules) { 949 | foreach ($installedModule in $installedModules) { 950 | $installedModuleVersion = GetModuleVersion $installedModule 951 | Write-Verbose "Found installed '$moduleName' module version '$installedModuleVersion'." 952 | 953 | if (IsSupportedVersion $installedModuleVersion $moduleMinVersion $moduleMaxVersion) { 954 | 955 | Write-Verbose "Module '$moduleName' version '$moduleVersion' is supported." 956 | $isInstalled = $true 957 | break 958 | } 959 | 960 | Write-Verbose "Module '$moduleName' version '$moduleVersion' is not supported." 961 | Write-Verbose "Supported module '$moduleName' versions are: '$moduleMinVersion'-'$moduleMaxVersion'." 962 | } 963 | } 964 | } 965 | 966 | if (!$isInstalled) { 967 | 968 | # Download module if needed. 969 | Write-Verbose "Installing module '$moduleName'." 970 | Install-Module -Name $moduleName @cmdArgs -Force -Scope CurrentUser -ErrorAction Stop 971 | } 972 | 973 | # Import module into the process. 974 | Write-Verbose "Importing module '$moduleName'." 975 | Import-Module $moduleName -ErrorAction Stop -Force @cmdArgs 976 | Write-Verbose "Imported module '$moduleName'." 977 | 978 | } 979 | } 980 | catch { 981 | $errMsg = "Cannot load module '$module'." 982 | throw (New-Object System.Exception($errMsg, $_.Exception)) 983 | } 984 | finally { 985 | WriteDebug "Exiting LoadModules." 986 | } 987 | } 988 | 989 | #-------------------------------------------------------------------------- 990 | # GetScriptVersion 991 | # Returns script version info. 992 | function GetScriptVersion { 993 | [CmdletBinding()] 994 | param ( 995 | ) 996 | WriteDebug "Entered GetScriptVersion." 997 | 998 | $versionInfo = Get-ScriptVersion 999 | $scriptName = (Get-Item $PSCommandPath).Basename 1000 | 1001 | WriteDebug "Exiting GetScriptVersion." 1002 | 1003 | return ($scriptName + 1004 | " v" + $versionInfo["Version"] + 1005 | " " + $versionInfo["Copyright"]) 1006 | } 1007 | 1008 | #-------------------------------------------------------------------------- 1009 | # GetPowerShellVersion 1010 | # Returns PowerShell version info. 1011 | function GetPowerShellVersion { 1012 | [CmdletBinding()] 1013 | param ( 1014 | ) 1015 | WriteDebug "Entered GetPowerShellVersion." 1016 | 1017 | $psVersion = $PSVersionTable.PSVersion 1018 | 1019 | WriteDebug "Exiting GetPowerShellVersion." 1020 | 1021 | return ($psVersion.Major.ToString() + '.' + 1022 | $psVersion.Minor.ToString() + '.' + 1023 | $psVersion.Build.ToString() + '.' + 1024 | $psVersion.Revision.ToString()) 1025 | } 1026 | 1027 | #-------------------------------------------------------------------------- 1028 | # GetCommandLineArgs 1029 | # Returns command-line arguments as a string. 1030 | function GetCommandLineArgs { 1031 | [CmdletBinding()] 1032 | param ( 1033 | ) 1034 | WriteDebug "Entered GetCommandLineArgs." 1035 | 1036 | $commandLine = "" 1037 | if ($args.Count -gt 0) { 1038 | 1039 | for ($i = 0; $i -lt $args.Count; $i++) { 1040 | if ($args[$i].Contains(" ")) { 1041 | $commandLine = $commandLine + '"' + $args[$i] + '" ' 1042 | } else { 1043 | $commandLine = $commandLine + $args[$i] + ' ' 1044 | } 1045 | } 1046 | } 1047 | 1048 | WriteDebug "Exiting GetCommandLineArgs." 1049 | 1050 | return $commandLine.Trim() 1051 | } 1052 | 1053 | #-------------------------------------------------------------------------- 1054 | # FormatError 1055 | # Returns error message from exception and inner exceptions. 1056 | function FormatError { 1057 | [CmdletBinding()] 1058 | param ( 1059 | $errors 1060 | ) 1061 | 1062 | if (!$errors -or $errors.Count -lt 1) { 1063 | return $null 1064 | } 1065 | 1066 | [System.Exception]$ex = $errors[0].Exception 1067 | 1068 | [string]$message = $null 1069 | 1070 | while ($ex) { 1071 | if ($message) { 1072 | $message += " $($ex.Message)" 1073 | } else { 1074 | $message = $ex.Message 1075 | } 1076 | 1077 | $ex = $ex.InnerException 1078 | } 1079 | 1080 | return $message 1081 | } 1082 | 1083 | #-------------------------------------------------------------------------- 1084 | # WriteException 1085 | # Writes exception to console. 1086 | function WriteException { 1087 | [CmdletBinding()] 1088 | param ( 1089 | $errors 1090 | ) 1091 | 1092 | WriteError (FormatError $errors) 1093 | } 1094 | 1095 | #-------------------------------------------------------------------------- 1096 | # WriteLogException 1097 | # Logs exception and inner exceptions. 1098 | function WriteLogException { 1099 | [CmdletBinding()] 1100 | param ( 1101 | $errors 1102 | ) 1103 | 1104 | Write-LogError (FormatError $errors) 1105 | } 1106 | 1107 | #-------------------------------------------------------------------------- 1108 | # WriteDebug 1109 | # Writes debug messages to console. 1110 | function WriteDebug { 1111 | #[CmdletBinding()] 1112 | param ( 1113 | $message 1114 | ) 1115 | if ($message -and $DebugPreference -ne 'SilentlyContinue') { 1116 | Write-Verbose $message 1117 | } 1118 | } 1119 | 1120 | #-------------------------------------------------------------------------- 1121 | # WriteError 1122 | # Writes error message to console. 1123 | function WriteError { 1124 | [CmdletBinding()] 1125 | param ( 1126 | $message 1127 | ) 1128 | 1129 | if ($message) { 1130 | [Console]::ForegroundColor = 'red' 1131 | [Console]::BackgroundColor = 'black' 1132 | [Console]::WriteLine($message) 1133 | [Console]::ResetColor() 1134 | } 1135 | } 1136 | 1137 | #-------------------------------------------------------------------------- 1138 | # StartLogging 1139 | # Initializes log settings. 1140 | function StartLogging { 1141 | [CmdletBinding()] 1142 | param( 1143 | ) 1144 | WriteDebug "Entered StartLogging." 1145 | 1146 | $logArgs = @{} 1147 | 1148 | if ($Script:LogLevel) { 1149 | $logArgs.Add("LogLevel", $Script:LogLevel) 1150 | } 1151 | 1152 | if ($Script:Log -and !$Script:LogFile) { 1153 | $logFileName = "$Script:Mode.log" 1154 | 1155 | $Script:LogFile = Join-Path $Script:BackupDir $logFileName 1156 | } 1157 | 1158 | if ($Script:LogFile) { 1159 | Write-Verbose "Setting log file to '$Script:LogFile'." 1160 | $logArgs.Add("FilePath", $Script:LogFile) 1161 | } else { 1162 | Write-Verbose "Not using log file." 1163 | $logArgs.Add("File", $false) 1164 | } 1165 | 1166 | if ($script:ErrorLog -and !$script:ErrorLogFile) { 1167 | $errorLogFileName = "$Script:Mode.err.log" 1168 | 1169 | $Script:ErrorLogFile = Join-Path $Script:BackupDir $errorLogFileName 1170 | } 1171 | 1172 | if ($Script:ErrorLogFile) { 1173 | Write-Verbose "Setting error log file to '$Script:ErrorLogFile'." 1174 | $logArgs.Add("ErrorFilePath", $Script:ErrorLogFile) 1175 | } else { 1176 | Write-Verbose "Not using error log file." 1177 | $logArgs.Add("ErrorFile", $false) 1178 | } 1179 | 1180 | # If script was launched with the -Quiet switch, do not output log to console. 1181 | if ($Script:Quiet) { 1182 | Write-Verbose "Not logging to console because of the '-Quiet' switch." 1183 | $logArgs.Add("Console", $false) 1184 | } 1185 | 1186 | # Initialize log settings. 1187 | Write-Verbose "Initializing logging." 1188 | Start-Logging @logArgs 1189 | 1190 | WriteDebug "Exiting StartLogging." 1191 | } 1192 | 1193 | #-------------------------------------------------------------------------- 1194 | # StopLogging 1195 | # Clears logging resources. 1196 | function StopLogging { 1197 | [CmdletBinding()] 1198 | param( 1199 | ) 1200 | WriteDebug "Entered StopLogging." 1201 | 1202 | if (Get-Module -Name $MODULE_StreamLogging) { 1203 | if (Test-LoggingStarted) { 1204 | try { 1205 | Write-Verbose "Uninitializing logging." 1206 | Stop-Logging 1207 | } 1208 | catch { 1209 | WriteError "Cannot stop logging." 1210 | WriteException $_ 1211 | 1212 | $Error.Clear() 1213 | } 1214 | } else { 1215 | Write-Verbose "Logging has not started, so nothing to uninitialize." 1216 | } 1217 | } else { 1218 | Write-Verbose "$MODULE_StreamLogging module is not loaded." 1219 | } 1220 | 1221 | WriteDebug "Exiting StopLogging." 1222 | } 1223 | 1224 | #-------------------------------------------------------------------------- 1225 | # Prologue 1226 | # Performs common action before the main execution logic. 1227 | function Prologue { 1228 | [CmdletBinding()] 1229 | param( 1230 | ) 1231 | WriteDebug "Entered Prologue." 1232 | 1233 | # Display script version info. 1234 | if (!($Script:NoLogo)){ 1235 | Write-LogInfo (GetScriptVersion) 1236 | } 1237 | 1238 | Write-LogInfo "Script started at:" 1239 | Write-LogInfo $Script:StartTime -Indent 1 1240 | 1241 | Write-LogInfo "Running as:" 1242 | Write-LogInfo $env:UserName -Indent 1 1243 | 1244 | # Get script 1245 | $scriptArgs = GetCommandLineArgs 1246 | 1247 | # Only write command-line arguments to the log file. 1248 | if ($scriptArgs) { 1249 | Write-LogInfo "Command-line arguments:" 1250 | Write-LogInfo $scriptArgs -Indent 1 -NoConsole 1251 | } 1252 | 1253 | # Only write logging configuration to the log file. 1254 | $loggingConfig = Get-LoggingConfig -Compress 1255 | Write-LogDebug "Logging configuration:" -NoConsole 1256 | Write-LogDebug $loggingConfig -Indent 1 -NoConsole 1257 | 1258 | WriteDebug "Exiting Prologue." 1259 | } 1260 | 1261 | #-------------------------------------------------------------------------- 1262 | # Epilogue 1263 | # Performs common action after the main execution logic. 1264 | function Epilogue { 1265 | [CmdletBinding()] 1266 | param( 1267 | ) 1268 | WriteDebug "Entered Epilogue." 1269 | 1270 | try { 1271 | if (!$Script:ErrorResult) { 1272 | if ($Script:Mode -ne $MODE_RESTORE) { 1273 | Write-LogInfo "Plex backup size:" 1274 | } else { 1275 | Write-LogInfo "Plex app data size:" 1276 | } 1277 | 1278 | Write-LogInfo "$($Script:ObjectCount) files and folders" -Indent 1 1279 | 1280 | Write-LogInfo "$($Script:BackupSize) GB of data" -Indent 1 1281 | } 1282 | 1283 | $runTime = (New-TimeSpan -Start $Script:StartTime -End $Script:EndTime). 1284 | ToString("hh\:mm\:ss\.fff") 1285 | 1286 | Write-LogInfo "Script ended at:" 1287 | Write-LogInfo $Script:EndTime -Indent 1 1288 | 1289 | Write-LogInfo "Script ran for (hr:min:sec.msec):" 1290 | Write-LogInfo $runTime -Indent 1 1291 | 1292 | Write-LogInfo "Script execution result:" 1293 | if ($Script:ErrorResult) { 1294 | Write-LogInfo "ERROR" -Indent 1 1295 | } else { 1296 | Write-LogInfo "SUCCESS" -Indent 1 1297 | } 1298 | 1299 | Write-LogInfo "Done." 1300 | } 1301 | catch { 1302 | WriteLogException $_ 1303 | 1304 | $Error.Clear() 1305 | } 1306 | 1307 | WriteDebug "Exiting Epilogue." 1308 | } 1309 | 1310 | #---------------------------[ CUSTOM FUNCTIONS ]--------------------------- 1311 | 1312 | # TODO: IMPLEMENT THE FOLLOWING FUNCTIONS AND ADD YOUR OWN IF NEEDED. 1313 | 1314 | #-------------------------------------------------------------------------- 1315 | # InitGlobals 1316 | # Initializes global variables. 1317 | function InitGlobals { 1318 | [CmdletBinding()] 1319 | param( 1320 | ) 1321 | WriteDebug "Entered InitGlobals." 1322 | 1323 | # First, set up the script execution mode. 1324 | Write-Verbose "Validating script execution mode." 1325 | if ($Script:Backup) { 1326 | $Script:Mode = $MODE_BACKUP 1327 | } 1328 | elseif ($Script:Continue) { 1329 | $Script:Mode = $MODE_CONTINUE 1330 | } 1331 | elseif ($Script:Restore) { 1332 | $Script:Mode = $MODE_RESTORE 1333 | } else { 1334 | if (!$Script:Mode) { 1335 | $Script:Mode = $MODE_BACKUP 1336 | } 1337 | } 1338 | 1339 | # Set up the backup type. 1340 | Write-Verbose "Validating backup type." 1341 | if ($Script:Robocopy) { 1342 | $Script:Type = $TYPE_ROBOCOPY 1343 | } 1344 | elseif ($Script:SevenZip) { 1345 | $Script:Type = $TYPE_7ZIP 1346 | } 1347 | 1348 | # For 7-zip archival, change default '.zip' extension to '.7z'. 1349 | if ($Script:Type -eq $TYPE_7ZIP) { 1350 | $Script:ZipFileExt = $FILE_EXT_7ZIP 1351 | } 1352 | 1353 | # If backup folder is specified, use its parent as the root. 1354 | if ($Script:BackupDir) { 1355 | $Script:BackupRootDir = Split-Path -Path $Script:BackupDir -Parent 1356 | } 1357 | 1358 | # Get the name and path of the backup directory. 1359 | try { 1360 | $Script:BackupDirName, $Script:BackupDir = GetBackupDirAndPath 1361 | } 1362 | catch { 1363 | throw (New-Object System.Exception( ` 1364 | "Cannot determine name and/or path of the backup folder.", ` 1365 | $_.Exception)) 1366 | } 1367 | 1368 | try { 1369 | # Determine path to Plex Media Server executable. 1370 | $Script:PlexServerPath = 1371 | GetPlexServerPath 1372 | } 1373 | catch { 1374 | if (!$Script:Inactive) { 1375 | throw (New-Object System.Exception( ` 1376 | "Cannot validate Plex Media Server executable path.", ` 1377 | $_.Exception)) 1378 | } else { 1379 | Write-Verbose (FormatError $_) 1380 | Write-Verbose "Will continue because the '-Inactive' switch is turned on." 1381 | } 1382 | } 1383 | 1384 | # Get version information. 1385 | $Script:VersionFilePath= Join-Path $Script:BackupDir $VERSION_FILE_NAME 1386 | 1387 | $Script:PlexVersion = GetPlexVersion 1388 | $Script:BackupVersion = GetBackupVersion 1389 | 1390 | WriteDebug "Exiting InitGlobals." 1391 | } 1392 | 1393 | #-------------------------------------------------------------------------- 1394 | # FormatRegFilename 1395 | # Converts registry key path to filename (basically, hashes the name). 1396 | function FormatRegFilename { 1397 | [CmdletBinding()] 1398 | param ( 1399 | $regKeyPath 1400 | ) 1401 | 1402 | #foreach ($token in $REG_KEY_CHAR_SUBS.GetEnumerator()) { 1403 | # $regKeyPath = $regKeyPath.Replace($token.key, $token.Value) 1404 | #} 1405 | $bytes = [System.Text.Encoding]::UTF8.GetBytes($regKeyPath) 1406 | $algorithm = [System.Security.Cryptography.HashAlgorithm]::Create('MD5') 1407 | $stringBuilder = New-Object System.Text.StringBuilder 1408 | 1409 | $algorithm.ComputeHash($bytes) | 1410 | ForEach-Object { 1411 | $null = $StringBuilder.Append($_.ToString("x2")) 1412 | } 1413 | 1414 | return $stringBuilder.ToString() 1415 | } 1416 | 1417 | #-------------------------------------------------------------------------- 1418 | # FormatFileSize 1419 | # Generates a string representation of the file size. 1420 | 1421 | Function FormatFileSize() { 1422 | param 1423 | ( 1424 | [string] 1425 | $path 1426 | ) 1427 | $result = $null 1428 | 1429 | if (!(Test-Path -Path $path -PathType Leaf)) { 1430 | return $result 1431 | } 1432 | 1433 | $size = (Get-Item $path).length 1434 | 1435 | if ($size -gt 1TB) { 1436 | $result = [string]::Format("{0:0.0} TB", $size / 1TB) 1437 | } 1438 | elseif ($size -gt 1GB) { 1439 | $result = [string]::Format("{0:0.0} GB", $size / 1GB) 1440 | } 1441 | elseif ($size -gt 1MB) { 1442 | $result = [string]::Format("{0:0.0} MB", $size / 1MB) 1443 | } 1444 | elseif ($size -gt 1KB) { 1445 | $result = [string]::Format("{0:0.0} KB", $size / 1KB) 1446 | } 1447 | elseif ($size -gt 0) { 1448 | $result = [string]::Format("{0:0.0} B", $size) 1449 | } 1450 | 1451 | return $result 1452 | } 1453 | 1454 | #-------------------------------------------------------------------------- 1455 | # GetTimestamp 1456 | # Returns current timestamp in a consistent format. 1457 | function GetTimestamp { 1458 | return $(Get-Date).ToString("yyyy/MM/dd HH:mm:ss.fff") 1459 | } 1460 | 1461 | #-------------------------------------------------------------------------- 1462 | # WakeUpDir 1463 | # Attempts to wake a remote host just in case if the backup folder is 1464 | # hosted on a remote share (pseudo Wake-onLAN command). 1465 | function WakeUpDir { 1466 | param ( 1467 | [string] 1468 | $path, 1469 | 1470 | [int] 1471 | $attempts = 6, 1472 | 1473 | [int] 1474 | $sleepTimeSec = 5 1475 | ) 1476 | WriteDebug "Entered WakeUpdDir." 1477 | 1478 | if ($path) { 1479 | # Just in case path points to a remote share on a sleeping device, 1480 | # try waking it up (a directory listing should do it). 1481 | for ($i = 0; $i -lt $attempts; $i++) { 1482 | try { 1483 | Write-Verbose "Waking up '$path'." 1484 | Get-ChildItem -Path $path | Out-Null 1485 | break 1486 | } 1487 | catch { 1488 | $Error.Clear() 1489 | Start-Sleep -Seconds $sleepTimeSec 1490 | } 1491 | } 1492 | } 1493 | 1494 | WriteDebug "Exiting WakeUpDir." 1495 | } 1496 | 1497 | #-------------------------------------------------------------------------- 1498 | # GetNewBackupDirName 1499 | # Generates the new name of the backup subfolder based on the current 1500 | # timestamp in the format: YYYYMMDDhhmmss. 1501 | function GetNewBackupDirName { 1502 | param ( 1503 | ) 1504 | 1505 | return ($Script:StartTime).ToString($BACKUP_DIRNAMEFORMAT) 1506 | } 1507 | 1508 | #-------------------------------------------------------------------------- 1509 | # GetLastBackupDirPath 1510 | # Returns the path of the most recent backup folder. 1511 | function GetLastBackupDirPath { 1512 | param ( 1513 | ) 1514 | WriteDebug "Entered GetLastBackupDirPath." 1515 | [string]$path = $null 1516 | 1517 | WakeUpDir $Script:BackupRootDir 1518 | 1519 | Write-Verbose "Checking backup root folder '$Script:BackupRootDir'." 1520 | if (!(Test-Path -Path $Script:BackupRootDir -PathType Container)) { 1521 | throw "Backup root folder '$Script:BackupRootDir' does not exist." 1522 | } 1523 | 1524 | # Get all folders with names from newest to oldest. 1525 | $oldBackupDirs = Get-ChildItem -Path $Script:BackupRootDir -Directory | 1526 | Where-Object { $_.Name -match $REGEX_BACKUPDIRNAMEFORMAT } | 1527 | Sort-Object -Descending 1528 | 1529 | # Check if there is at least one matching subdirectory. 1530 | if ($oldBackupDirs.Count -gt 0) { 1531 | $path = $oldBackupDirs[0].FullName 1532 | } 1533 | 1534 | WriteDebug "Exiting GetLastBackupDirPath." 1535 | return $path 1536 | } 1537 | 1538 | #-------------------------------------------------------------------------- 1539 | # GetBackupDirAndPath 1540 | # Returns name and path of the backup directory that will be used for the 1541 | # running backup job (it can be an existing or a new directory depending 1542 | # on the backup mode). 1543 | function GetBackupDirAndPath { 1544 | param ( 1545 | ) 1546 | WriteDebug "Entered GetBackupDirAndPath." 1547 | 1548 | [string]$name = $null 1549 | [string]$path = $null 1550 | 1551 | if ($Script:BackupDir) { 1552 | WakeUpDir $Script:BackupDir 1553 | 1554 | $name = (Split-Path -Path $Script:BackupDir -Leaf) 1555 | $path = $Script:BackupDir 1556 | } else { 1557 | WakeUpDir $Script:BackupRootDir 1558 | 1559 | # For Restore and Continue, get the latest timestamped subfolder from the backup root. 1560 | if ($Script:Mode -ne $MODE_BACKUP) { 1561 | $path = GetLastBackupDirPath 1562 | 1563 | # For Restore mode we must have at least one matching folder; 1564 | # for Continue, it's okay if none are found (we'll create a new one a 1565 | # as if running in the Backup mode). 1566 | if (!$path -and ($Script:Mode -eq $MODE_RESTORE)) { 1567 | throw "No folder matching the timestamp regex format " + 1568 | "'$REGEX_BACKUPDIRNAMEFORMAT' found in the " + 1569 | "backup root folder '$Script:BackupRootDir'." 1570 | } 1571 | } 1572 | 1573 | if ($path) { 1574 | $name = (Split-Path -Path $path -Leaf) 1575 | } else { 1576 | # For the Backup mode, generate a new timestamp-based folder name. 1577 | # Do the same for Continue mode if we did not find the last backup folder. 1578 | $name = GetNewBackupDirName 1579 | $path = (Join-Path $Script:BackupRootDir $name) 1580 | } 1581 | } 1582 | 1583 | WriteDebug "Exiting GetBackupDirAndPath." 1584 | return $name, $path 1585 | } 1586 | 1587 | #-------------------------------------------------------------------------- 1588 | # InitMail 1589 | # Initializes SMTP and mail notification setting. 1590 | function InitMail { 1591 | [CmdletBinding()] 1592 | param( 1593 | ) 1594 | WriteDebug "Entered InitMail." 1595 | 1596 | # Set up mail configuration. 1597 | if (!$Script:SmtpServer) { 1598 | if ($Script:SendMail -ne $SEND_MAIL_NEVER) { 1599 | Write-Verbose "Will not send email notification because SMTP server is not specified." 1600 | } 1601 | 1602 | WriteDebug "Exiting InitSmtp." 1603 | return 1604 | } 1605 | 1606 | try { 1607 | if (!(MustSendMail)) { 1608 | if ($Script:SendMail -ne $SEND_MAIL_NEVER) { 1609 | Write-Verbose "Will not send email notification because the condition is not met." 1610 | $Script:SendMail = $SEND_MAIL_NEVER 1611 | } else { 1612 | Write-Verbose "Email notification will not be sent." 1613 | } 1614 | 1615 | return 1616 | } 1617 | 1618 | # If we do not support anonymous SMTP, need to get credentials. 1619 | if (!$Script:Anonymous) { 1620 | # If credential file path is not specified, use the default. 1621 | if (!$Script:CredentialFile) { 1622 | $Script:CredentialFile = $PSCommandPath + $FILE_EXT_CRED 1623 | } 1624 | 1625 | # If credential file exists, read credentials from the file. 1626 | if (Test-Path -Path $Script:CredentialFile -PathType Leaf) { 1627 | try { 1628 | Write-Verbose "Importing SMTP credentials from '$Script:CredentialFile'." 1629 | $Script:Credential = Import-CliXml -Path $Script:CredentialFile 1630 | } 1631 | catch { 1632 | Write-Verbose ("Cannot import SMTP credentials from '$Script:CredentialFile'. " + 1633 | "If the file is no longer valid, please delete it and try again.") 1634 | } 1635 | 1636 | # If we did not get credentail from the file and user prompt not specified, 1637 | # nothing we can do. 1638 | if (!$Script:Credential) { 1639 | Write-Verbose ("SMTP credentials in '$Script:CredentialFile' are empty. " + 1640 | "If the file is no longer valid, please delete it and try again.") 1641 | } 1642 | } 1643 | 1644 | # Show prompt to allow user to enter credentials. 1645 | if (!$Script:Credential) { 1646 | try { 1647 | $Script:Credential = $Host.UI.PromptForCredential( 1648 | "SMTP Server Authentication", 1649 | "Please enter your credentials for " + $Script:SmtpServer, 1650 | "", 1651 | "", 1652 | [System.Management.Automation.PSCredentialTypes]::Generic, 1653 | [System.Management.Automation.PSCredentialUIOptions]::None) 1654 | } 1655 | catch { 1656 | throw (New-Object System.Exception( ` 1657 | "Cannot get SMTP credentials from the Windows prompt.", $_.Exception)) 1658 | } 1659 | 1660 | # If we did not get credentail from the file and user prompt not specified, 1661 | # nothing we can do. 1662 | if (!$Script:Credential) { 1663 | throw "No SMTP credentials provided." 1664 | } 1665 | 1666 | # Save entered credentials if needed. 1667 | if ($Script:SaveCredential) { 1668 | try { 1669 | Write-Verbose "Exporting SMTP credentials to '$Script:CredentialFile'." 1670 | 1671 | Export-CliXml -Path $Script:CredentialFile -InputObject $Script:Credential 1672 | } 1673 | catch { 1674 | throw (New-Object System.Exception( ` 1675 | "Cannot export SMTP credentials to '$Script:CredentialFile'.", $_.Exception)) 1676 | } 1677 | } 1678 | } 1679 | } 1680 | 1681 | if (!$Script:From -and !$Script:To -and (!$Script:Credential -or !$Script:Credential.UserName)) { 1682 | throw "No address specified for email notification." 1683 | } 1684 | 1685 | # If the From address is not specified, get it from credential object or To address. 1686 | if (!$Script:From) { 1687 | if ($Script:Credential -and $Script:Credential.UserName) { 1688 | $Script:From = $Script:Credential.UserName 1689 | } else { 1690 | $Script:From = $Script:To 1691 | } 1692 | } 1693 | 1694 | # If the To address is not specified, get it from the From address. 1695 | if (!$Script:To) { 1696 | $Script:To = $Script:From 1697 | } 1698 | 1699 | # If the To or From address not specified, do not send mail. 1700 | if (!$Script:From -or !$Script:To) { 1701 | Write-Verbose "Will not send email notification because of missing address." 1702 | $Script:SendMail = $SEND_MAIL_NEVER 1703 | } 1704 | } 1705 | finally { 1706 | WriteDebug "Exiting InitMail." 1707 | } 1708 | } 1709 | 1710 | #-------------------------------------------------------------------------- 1711 | # Validate7Zip 1712 | # Validates path to the 7-zip command-line tool. 1713 | function Validate7Zip { 1714 | [CmdletBinding()] 1715 | param( 1716 | ) 1717 | WriteDebug "Entered Validate7Zip." 1718 | 1719 | try { 1720 | if ($Script:Type -eq $TYPE_7ZIP) { 1721 | if (!$Script:ArchiverPath) { 1722 | throw "Please set the value of parameter 'ArchiverPath' " + 1723 | "to point to the 7-zip command-line tool (7z.exe)." 1724 | } 1725 | 1726 | if (!(Test-Path -Path $Script:ArchiverPath -PathType Leaf)) { 1727 | throw "The 7-zip command line tool is not found in " + 1728 | "'$Script:ArchiverPath'. " + 1729 | "Please define a valid path in parameter 'ArchiverPath'." 1730 | } 1731 | } 1732 | } 1733 | finally { 1734 | WriteDebug "Exiting Validate7Zip." 1735 | } 1736 | } 1737 | 1738 | 1739 | #-------------------------------------------------------------------------- 1740 | # Get7ZipError 1741 | # Get error message for error code returned by 7-zip. 1742 | # Based on https://documentation.help/7-Zip/exit_codes.htm 1743 | function Get7ZipError { 1744 | [CmdletBinding()] 1745 | param( 1746 | [int] 1747 | $exitCode 1748 | ) 1749 | 1750 | $msg = "Unknown error." 1751 | 1752 | if ($exitCode -eq 0) { 1753 | $msg = "No error." 1754 | } elseif ($exitCode -eq 1) { 1755 | $msg = "Warning (Non fatal error(s)). For example, one or more files were locked by some other application, so they were not compressed." 1756 | } elseif ($exitCode -eq 2) { 1757 | $msg = "Fatal error." 1758 | } elseif ($exitCode -eq 7) { 1759 | $msg = "Command line error." 1760 | } elseif ($exitCode -eq 8) { 1761 | $msg = "Not enough memory for operation." 1762 | } elseif ($exitCode -eq 255) { 1763 | $msg = "User stopped the process." 1764 | } 1765 | 1766 | return "7-zip returned error code '$exitCode': $msg" 1767 | } 1768 | 1769 | #-------------------------------------------------------------------------- 1770 | # ValidateVersion 1771 | # Validates backup version. 1772 | function ValidateVersion { 1773 | [CmdletBinding()] 1774 | param( 1775 | ) 1776 | WriteDebug "Entered ValidateVersion." 1777 | 1778 | if ($Script:Mode -eq $MODE_BACKUP) { 1779 | Write-Verbose "Version validation skipped during backup." 1780 | } else { 1781 | if (!$Script:BackupVersion) { 1782 | Write-Verbose ` 1783 | "Version validation skipped because of the missing backup version." 1784 | } 1785 | elseif (!$Script:PlexVersion) { 1786 | Write-Verbose ` 1787 | "Version validation skipped because of the missing Plex Media Server version." 1788 | } else { 1789 | Write-Verbose ("Validating the backup version '$BackupVersion' " + 1790 | "against the Plex Media Server version '$Script:PlexVersion'.") 1791 | 1792 | if ($Script:PlexVersion -ne $Script:BackupVersion) { 1793 | Write-Verbose "Version mismatch is detected." 1794 | 1795 | if (!$Script:NoVersion) { 1796 | throw "Backup version '$Script:BackupVersion' does not match " + 1797 | "version '$Script:PlexVersion' of the Plex Media Server. " + 1798 | "To ignore version check, run the script with the " + 1799 | "'-NoVersion' flag." 1800 | } 1801 | } 1802 | } 1803 | } 1804 | 1805 | WriteDebug "Exiting ValidateVersion." 1806 | } 1807 | 1808 | #-------------------------------------------------------------------------- 1809 | # ValidateData 1810 | # Validates required directories. 1811 | function ValidateData { 1812 | [CmdletBinding()] 1813 | param( 1814 | ) 1815 | WriteDebug "Entered ValidateData." 1816 | 1817 | try { 1818 | # Make sure we have backup folder. 1819 | if (!$Script:BackupDir) { 1820 | throw "Path to backup data folder is not specified. " + 1821 | "Please, make sure you set one of the following switches: " + 1822 | "'-BackupRootDir', '-BackupDir'." 1823 | } 1824 | 1825 | # For restore, we need the backup folder. 1826 | if ($Script:Mode -eq $MODE_RESTORE) { 1827 | if (!(Test-Path -Path $Script:BackupDir -PathType Container)) { 1828 | throw "Backup data folder '$Script:BackupDir' does not exist." 1829 | } 1830 | } 1831 | 1832 | # We always need Plex app data folder. 1833 | if (!$Script:PlexAppDataDir) { 1834 | throw "Path to Plex app data folder is not specified. " + 1835 | "It must default to '$env:LOCALAPPDATA\Plex Media Server' " + 1836 | "but it is empty now. What have you done?" 1837 | } 1838 | 1839 | # Plex app data folder must exist. 1840 | if ($Script:Mode -ne $MODE_RESTORE) { 1841 | if (!(Test-Path -Path $Script:PlexAppDataDir -PathType Container)) { 1842 | throw "Plex app data folder '$Script:PlexAppDataDir' does not exist." 1843 | } 1844 | } 1845 | 1846 | # Check the important registry key. 1847 | if ($Script:Mode -ne $MODE_RESTORE) { 1848 | $key = $PLEX_REG_KEYS[0] 1849 | 1850 | if (!(Test-Path -Path $key)) { 1851 | throw "Registry key '$key' does not exist." 1852 | } 1853 | } 1854 | } 1855 | finally { 1856 | WriteDebug "Exiting ValidateData." 1857 | } 1858 | } 1859 | 1860 | #-------------------------------------------------------------------------- 1861 | # ValidateSingleInstance 1862 | # Validates that only one instance of the script is running. 1863 | function ValidateSingleInstance { 1864 | [CmdletBinding()] 1865 | param( 1866 | ) 1867 | WriteDebug "Entered ValidateSingleInstance." 1868 | 1869 | try { 1870 | # Mutex for single-instance operation. 1871 | if (!$Script:NoSingleton) { 1872 | Write-Verbose "Making sure the script is not already running." 1873 | 1874 | if (!(Enter-SingleInstance $MUTEX_NAME)) { 1875 | throw "The script is already running." 1876 | } 1877 | } 1878 | } 1879 | finally { 1880 | WriteDebug "Exiting ValidateSingleInstance." 1881 | } 1882 | } 1883 | 1884 | #-------------------------------------------------------------------------- 1885 | # MustSendMail 1886 | # Returns 'true' if conditions for sending email notification about the 1887 | # operation result are met; returns 'false' otherwise. 1888 | function MustSendMail { 1889 | param ( 1890 | [ValidateSet($null, $true, $false)] 1891 | [object] 1892 | $success = $null 1893 | ) 1894 | 1895 | if ($Script:SendMail -eq $SEND_MAIL_NEVER) { 1896 | return $false 1897 | } 1898 | 1899 | if ($Script:SendMail -eq $SEND_MAIL_ALWAYS) { 1900 | return $true 1901 | } 1902 | 1903 | if ($Script:SendMail -eq $SEND_MAIL_SUCCESS) { 1904 | if ($success -ne $false) { 1905 | return $true 1906 | } else { 1907 | return $false 1908 | } 1909 | } 1910 | 1911 | if ($Script:SendMail -eq $SEND_MAIL_ERROR) { 1912 | if ($success -ne $true) { 1913 | return $true 1914 | } else { 1915 | return $false 1916 | } 1917 | } 1918 | 1919 | if ($Script:SendMail.StartsWith($SEND_MAIL_BACKUP)) { 1920 | if ($Script:Mode -eq $MODE_RESTORE) { 1921 | return $false 1922 | } 1923 | if (($Script:SendMail.EndsWith("Error")) -and ($success -eq $true)) { 1924 | return $false 1925 | } 1926 | if (($Script:SendMail.EndsWith("Success")) -and ($success -eq $false)) { 1927 | return $false 1928 | } 1929 | 1930 | return $true 1931 | } 1932 | 1933 | if ($Script:SendMail.StartsWith($SEND_MAIL_RESTORE)) { 1934 | if ($Script:Mode -ne $MODE_RESTORE) { 1935 | return $false 1936 | } 1937 | if (($Script:SendMail.EndsWith("Error")) -and ($success -eq $true)) { 1938 | return $false 1939 | } 1940 | if (($Script:SendMail.EndsWith("Success")) -and ($success -eq $false)) { 1941 | return $false 1942 | } 1943 | 1944 | return $true 1945 | } 1946 | 1947 | return $true 1948 | } 1949 | 1950 | #-------------------------------------------------------------------------- 1951 | # MustSendAttachment 1952 | # Returns 'true' if conditions for sending the log file as an attachment 1953 | # are met; returns 'false' otherwise. 1954 | function MustSendAttachment { 1955 | [CmdletBinding()] 1956 | param ( 1957 | ) 1958 | if ($Script:SendLogFile -eq $SEND_LOGFILE_NEVER) { 1959 | return $false 1960 | } 1961 | 1962 | $success = $true 1963 | 1964 | if ($Script:ErrorResult) { 1965 | $success = $false 1966 | } 1967 | 1968 | if (!$Script:LogFile) { 1969 | return $false 1970 | } 1971 | 1972 | if (!(Test-Path -Path $Script:LogFile -PathType Leaf)) { 1973 | return $false 1974 | } 1975 | 1976 | if ($Script:SendLogFile -eq $SEND_LOGFILE_ALWAYS) { 1977 | return $true 1978 | } 1979 | 1980 | if ($Script:SendLogFile -eq $SEND_LOGFILE_SUCCESS) { 1981 | if ($success -ne $true) { 1982 | return $false 1983 | } else { 1984 | return $true 1985 | } 1986 | } 1987 | 1988 | if ($Script:SendLogFile -eq $SEND_LOGFILE_ERROR) { 1989 | if ($success -ne $false) { 1990 | return $false 1991 | } else { 1992 | return $true 1993 | } 1994 | } 1995 | 1996 | return $true 1997 | } 1998 | 1999 | #-------------------------------------------------------------------------- 2000 | # GetPlexServerPath 2001 | # Returns the path of the running Plex Media Server executable. 2002 | function GetPlexServerPath { 2003 | param ( 2004 | ) 2005 | WriteDebug "Entered GetPlexServerPath." 2006 | 2007 | try { 2008 | # Get path of the Plex Media Server executable. 2009 | if (!$Script:PlexServerPath) { 2010 | $Script:PlexServerPath = Get-Process | 2011 | Where-Object {$_.Path -match $Script:PlexServerFileName + "$" } | 2012 | Select-Object -ExpandProperty Path 2013 | } 2014 | 2015 | # Make sure we got the Plex Media Server executable file path. 2016 | if (!$Script:PlexServerPath) { 2017 | throw "Cannot determine path of the the Plex Media Server executable file " + 2018 | "'$Script:PlexServerFileName' because it is not running." 2019 | } 2020 | 2021 | # Verify that the Plex Media Server executable file exists. 2022 | if (!(Test-Path -Path $Script:PlexServerPath -PathType Leaf)) { 2023 | throw "Plex Media Server executable file '$Script:PlexServerPath' does not exist." 2024 | } 2025 | } 2026 | finally { 2027 | WriteDebug "Exiting GetPlexServerPath." 2028 | } 2029 | 2030 | return $Script:PlexServerPath 2031 | } 2032 | 2033 | #-------------------------------------------------------------------------- 2034 | # GetPlexVersion 2035 | # Returns either file version of the current Plex Media Server executable. 2036 | function GetPlexVersion { 2037 | param ( 2038 | ) 2039 | WriteDebug "Entered GetPlexVersion." 2040 | 2041 | [string]$version = $null 2042 | [string]$path = $null 2043 | 2044 | if ($Script:PlexServerPath) { 2045 | $path = $Script:PlexServerPath 2046 | } 2047 | elseif ($DEFAULT_PLEX_SERVER_EXE_PATH) { 2048 | $path = $DEFAULT_PLEX_SERVER_EXE_PATH 2049 | } 2050 | 2051 | Write-Verbose "Setting Plex Media Server path for version checking to '$path'." 2052 | 2053 | try { 2054 | if (($path) -and (Test-Path -Path $path -PathType Leaf)) { 2055 | try { 2056 | # Get version info from the assembly. 2057 | Write-Verbose "Reading Plex Media Server version from '$path'." 2058 | $version = (Get-Item $path).VersionInfo.FileVersion.Trim() 2059 | Write-Verbose "Plex Media Server version: '$version'." 2060 | } 2061 | catch { 2062 | throw (New-Object System.Exception( ` 2063 | "Cannot get Plex Media Server version from '$path'.", ` 2064 | $_.Exception)) 2065 | } 2066 | } 2067 | } 2068 | finally { 2069 | WriteDebug "Exiting GetPlexVersion." 2070 | } 2071 | 2072 | return $version 2073 | } 2074 | 2075 | #-------------------------------------------------------------------------- 2076 | # GetBackupVersion 2077 | # Returns either version of the last saved backup (from the version.txt file). 2078 | function GetBackupVersion { 2079 | param ( 2080 | ) 2081 | WriteDebug "Entered GetBackupVersion." 2082 | 2083 | [string]$version = $null 2084 | 2085 | try { 2086 | if (($Script:VersionFilePath) -and (Test-Path -Path $Script:VersionFilePath -PathType Leaf)) { 2087 | try { 2088 | # Assume that this is a previous backup's version.txt file. 2089 | Write-Verbose "Reading backup version from '$Script:VersionFilePath'." 2090 | $version = (Get-Content -Path $Script:VersionFilePath).Trim() 2091 | Write-Verbose "Backup version: '$version'." 2092 | } 2093 | catch { 2094 | throw (New-Object System.Exception( ` 2095 | "Cannot get backup version from '$Script:VersionFilePath'.", ` 2096 | $_.Exception)) 2097 | } 2098 | } 2099 | } 2100 | finally { 2101 | WriteDebug "Exiting GetBackupVersion." 2102 | } 2103 | 2104 | return $version 2105 | } 2106 | 2107 | #-------------------------------------------------------------------------- 2108 | # SavePlexVersion 2109 | # Saves current file version of the Plex Media Server executable to 2110 | # the version.txt file. 2111 | function SavePlexVersion { 2112 | param ( 2113 | ) 2114 | WriteDebug "Entered SavePlexVersion." 2115 | 2116 | 2117 | if (!$Script:PlexVersion) { 2118 | Write-LogWarning "Undefined Plex Media Server Version, so it will not be saved." 2119 | } 2120 | elseif (!$Script:VersionFilePath) { 2121 | Write-LogWarning "Plex Media Server Version will not be saved because version file path is undefined." 2122 | } else { 2123 | if (!(Test-Path -Path $Script:VersionFilePath -PathType Leaf)) { 2124 | Write-Verbose "Overwriting '$Script:VersionFilePath'." 2125 | 2126 | if (!$Script:Test) { 2127 | New-Item -Path $Script:VersionFilePath -Type file -Force | Out-Null 2128 | } 2129 | } 2130 | 2131 | Write-LogInfo "Saving version:" 2132 | Write-LogInfo $Script:PlexVersion -Indent 1 2133 | Write-LogInfo "to:" 2134 | Write-LogInfo $Script:VersionFilePath -Indent 1 2135 | 2136 | if (!$Script:Test) { 2137 | $Script:PlexVersion | Set-Content -Path $Script:VersionFilePath 2138 | } 2139 | } 2140 | 2141 | WriteDebug "Exiting SavePlexVersion." 2142 | } 2143 | 2144 | #-------------------------------------------------------------------------- 2145 | # GetPlexServices 2146 | # Returns the list of Plex Windows services (identified by display names). 2147 | function GetPlexServices { 2148 | param ( 2149 | ) 2150 | WriteDebug "Entered GetPlexServices." 2151 | 2152 | $services = Get-Service | 2153 | Where-Object {$_.DisplayName -match $PlexServiceName} | 2154 | Where-Object {$_.status -eq 'Running'} 2155 | 2156 | if ($services) { 2157 | Write-Verbose "$($services.Count) Plex service(s) detected running." 2158 | } else { 2159 | Write-Verbose "No Plex services detected running." 2160 | 2161 | } 2162 | 2163 | WriteDebug "Exiting GetPlexServices." 2164 | return $services 2165 | } 2166 | 2167 | #-------------------------------------------------------------------------- 2168 | # StopPlexServices 2169 | # Stops running Plex Windows services. 2170 | function StopPlexServices { 2171 | param ( 2172 | [object[]] 2173 | $services 2174 | ) 2175 | WriteDebug "Entered StopPlexServices." 2176 | 2177 | # We'll keep track of every Plex service we successfully stopped. 2178 | $stoppedPlexServices = [System.Collections.ArrayList]@() 2179 | 2180 | if ($services.Count -gt 0) { 2181 | Write-LogInfo "Stopping Plex services:" 2182 | 2183 | foreach ($service in $services) { 2184 | Write-LogInfo $service.DisplayName -Indent 1 2185 | 2186 | try { 2187 | Stop-Service -Name $service.Name -Force 2188 | ($stoppedPlexServices.Add($service)) | Out-Null 2189 | } 2190 | catch { 2191 | Write-LogError "Failed to stop Windows service '$($service.DisplayName)'." 2192 | WriteLogException $_ 2193 | 2194 | $Error.Clear() 2195 | return $false, $stoppedPlexServices 2196 | } 2197 | 2198 | } 2199 | } 2200 | 2201 | WriteDebug "Exiting StopPlexServices." 2202 | return $true, $stoppedPlexServices 2203 | } 2204 | 2205 | #-------------------------------------------------------------------------- 2206 | # StartPlexServices 2207 | # Starts Plex Windows services. 2208 | function StartPlexServices { 2209 | param ( 2210 | [object[]] 2211 | $services 2212 | ) 2213 | WriteDebug "Entered StartPlexServices." 2214 | 2215 | if ($services -and ($services.Count -gt 0)) { 2216 | Write-LogInfo "Starting Plex services:" 2217 | 2218 | foreach ($service in $services) { 2219 | Write-LogInfo $service.DisplayName -Indent 1 2220 | 2221 | try { 2222 | Start-Service -Name $service.Name 2223 | } 2224 | catch { 2225 | Write-LogError "Failed to start Windows service '$($service.DisplayName)'." 2226 | WriteLogException $_ 2227 | 2228 | $Error.Clear() 2229 | # Non-critical error; can continue. 2230 | } 2231 | } 2232 | } 2233 | 2234 | WriteDebug "Exiting StartPlexServices." 2235 | } 2236 | 2237 | #-------------------------------------------------------------------------- 2238 | # StopPlexMediaServer 2239 | # Stops a running instance of Plex Media Server. 2240 | function StopPlexMediaServer { 2241 | [CmdletBinding()] 2242 | param ( 2243 | ) 2244 | WriteDebug "Entered StopPlexMediaServer." 2245 | 2246 | try { 2247 | # If we have the path to Plex Media Server executable, see if it's running. 2248 | if ($Script:PlexServerPath) { 2249 | $exeFileName = Get-Process | 2250 | Where-Object {$_.Path -eq $Script:PlexServerPath } | 2251 | Select-Object -ExpandProperty Name 2252 | 2253 | # Stop Plex Media Server executable (if it is running). 2254 | if ($exeFileName) { 2255 | Write-LogInfo "Stopping Plex Media Server process:" 2256 | Write-LogInfo $Script:PlexServerFileName -Indent 1 2257 | 2258 | try { 2259 | # First, let's try to close Plex gracefully. 2260 | Write-Verbose "Trying to stop Plex Media Server process gracefully." 2261 | taskkill /im $Script:PlexServerFileName >$nul 2>&1 2262 | 2263 | $timeoutSeconds = 60 2264 | 2265 | # Keep checking to see if Plex is not longer running for at most 60 seconds. 2266 | Write-Verbose "Checking if Plex Media Server process stopped gracefully." 2267 | do { 2268 | # Sleep for a second. 2269 | Start-Sleep -Seconds 1 2270 | $timeoutSeconds-- 2271 | 2272 | } while (($timeoutSeconds -gt 0) -and 2273 | (Get-Process -ErrorAction SilentlyContinue | 2274 | Where-Object {$_.Path -match $Script:PlexServerFileName + "$" })) 2275 | 2276 | # If Plex is still running, kill it along with all child processes forcefully. 2277 | if (Get-Process -ErrorAction SilentlyContinue | 2278 | Where-Object {$_.Path -match $Script:PlexServerFileName + "$" }) { 2279 | 2280 | Write-Verbose "Failed to stop Plex Media Server process gracefully." 2281 | Write-Verbose "Killing Plex Media Server process." 2282 | 2283 | taskkill /f /im $Script:PlexServerFileName /t >$nul 2>&1 2284 | } 2285 | } 2286 | catch { 2287 | throw (New-Object System.Exception( ` 2288 | "Error stopping Plex Media Server.", $_.Exception)) 2289 | } 2290 | } 2291 | } 2292 | } 2293 | finally { 2294 | WriteDebug "Exiting StopPlexMediaServer." 2295 | } 2296 | } 2297 | 2298 | #-------------------------------------------------------------------------- 2299 | # StartPlexMediaServer 2300 | # Launches Plex Media Server. 2301 | function StartPlexMediaServer { 2302 | [CmdletBinding()] 2303 | param ( 2304 | ) 2305 | WriteDebug "Entered StartPlexMediaServer." 2306 | 2307 | if ($Script:PlexServerPath) { 2308 | Write-Verbose "Checking if Plex Media Server is already running." 2309 | 2310 | # Get name of the Plex Media Server executable (just to see if it is running). 2311 | $exeFileName = Get-Process | 2312 | Where-Object {$_.Path -match $Script:PlexServerFileName + "$" } | 2313 | Select-Object -ExpandProperty Name 2314 | 2315 | # Start Plex Media Server executable (if it is not running). 2316 | if ($exeFileName) { 2317 | Write-Verbose "Plex Media Server is already running." 2318 | } else { 2319 | Write-Verbose "Plex Media Server is not running." 2320 | 2321 | Write-LogInfo "Starting Plex Media Server:" 2322 | Write-LogInfo $Script:PlexServerPath -Indent 1 2323 | 2324 | try { 2325 | # Try to restart Plex not as Administrator. 2326 | # Starting it as Administrator seems to mess up share mappings. 2327 | # https://stackoverflow.com/questions/20218076/batch-file-drop-elevated-privileges-run-a-command-as-original-user 2328 | 2329 | # Start-Process $plexServerExePath -LoadUserProfile 2330 | if (($null -eq $Script:Machine) -or ($Script:Machine -eq "")) { 2331 | runas /trustlevel:0x20000 "$Script:PlexServerPath" 2332 | } else { 2333 | runas /machine:$Script:Machine /trustlevel:0x20000 "$Script:PlexServerPath" 2334 | } 2335 | } 2336 | catch { 2337 | Write-LogError "Failed to start Plex Media Server '$($service.DisplayName)'." 2338 | WriteLogException $_ 2339 | 2340 | $Error.Clear() 2341 | # Non-critical error; can continue. 2342 | } 2343 | } 2344 | } 2345 | 2346 | WriteDebug "Exiting StartPlexMediaServer." 2347 | } 2348 | 2349 | #-------------------------------------------------------------------------- 2350 | # FormatEmail 2351 | # Formats HTML body for the notification email. 2352 | function FormatEmail { 2353 | [CmdletBinding()] 2354 | param ( 2355 | ) 2356 | WriteDebug "Entered FormatEmail." 2357 | 2358 | $body = " 2359 | 2360 | 2361 | Plex Backup Report 2362 | 2363 | 2364 | 2365 | 2366 | 2375 | 2376 | 2377 | 2380 | 2381 | 2382 | 2385 | 2386 | 2387 | 2397 | 2398 | 2399 | 2408 | 2409 |
2367 | Plex backup script 2368 | #VALUE_SCRIPT_PATH# 2369 | completed the 2370 | #VALUE_BACKUP_MODE# 2371 | operation on 2372 | #VALUE_COMPUTER_NAME# 2373 | with the following result: 2374 |
2378 | #VALUE_RESULT# 2379 |
2383 | #VALUE_ERROR_INFO# 2384 |
2388 | The #VALUE_DATA_DIR_NAME# folder 2389 | #VALUE_DATA_DIR_PATH# 2390 | contains 2391 | #VALUE_BACKUP_OBJECTS# 2392 | objects 2393 | and 2394 | #VALUE_BACKUP_SIZE# GB 2395 | of data. 2396 |
2400 | The script started at 2401 | #VALUE_SCRIPT_START_TIME# 2402 | and ended at 2403 | #VALUE_SCRIPT_END_TIME# 2404 | running for 2405 | #VALUE_SCRIPT_DURATION# 2406 | (hr:min:sec.msec). 2407 |
2410 | 2411 | 2412 | " 2413 | $styleTextColor = 'color: #444444;' 2414 | $styleSuccessColor = 'color: #6aa84f;' 2415 | $styleErrorColor = 'color: #cc2200;' 2416 | 2417 | $styleResultColor = '' 2418 | $resultText = '' 2419 | 2420 | $styleErrorInfo = 'mso-hide: all;overflow: hidden;max-height: 0;display: none;line-height: 0;visibility: hidden;' 2421 | $styleErrorParagraph= $styleErrorInfo 2422 | 2423 | $styleDataInfo = 'mso-hide: all;overflow: hidden;max-height: 0;display: none;line-height: 0;visibility: hidden;' 2424 | $styleVarData = 'mso-hide: all;overflow: hidden;max-height: 0;display: none;line-height: 0;visibility: hidden;' 2425 | $styleDataParagraph = $styleDataInfo 2426 | 2427 | if ($Script:ErrorResult) { 2428 | $styleResultColor = $styleErrorColor 2429 | $resultText = 'ERROR' 2430 | } else { 2431 | $styleResultColor = $styleSuccessColor 2432 | $resultText = 'SUCCESS' 2433 | } 2434 | 2435 | $stylePage = 'max-width: 800px;border: 0;padding: 0;' 2436 | $styleFontFamily = 'font-family: Arial,Helvetica,sans-serif;' 2437 | $styleParagraph = 'padding-top: 8px;' 2438 | $styleTextFontSize = 'font-size: 13px;' 2439 | $styleResultTextFontSize = 'font-size: 18px;' 2440 | $styleTextFont = $styleFontFamily + $styleTextFontSize + $styleTextColor 2441 | $styleVarTextFont = $styleTextFont + 'font-weight: bold;' 2442 | $styleResultTextFont = $styleFontFamily + $styleResultTextFontSize + $styleResultColor + 'font-weight: bold;' 2443 | 2444 | $styleText = $styleTextFont 2445 | $styleVarText = $styleVarTextFont 2446 | $styleResult = $styleResultTextFont 2447 | 2448 | [string]$backupMode = $null 2449 | [string]$dataDirName = $null 2450 | [string]$dataDirPath = $null 2451 | 2452 | if ($Script:Type) { 2453 | $backupMode = $Script:Mode.ToUpper() + " -" + $Type.ToUpper() 2454 | } else { 2455 | $backupMode = $Script:Mode.ToUpper() 2456 | } 2457 | 2458 | if ($Script:Test) { 2459 | $backupMode += " (TEST)" 2460 | } 2461 | 2462 | if ($Script:Mode -eq $MODE_RESTORE) { 2463 | $dataDirName = "Plex app data" 2464 | $dataDirPath = $Script:PlexAppDataDir 2465 | } else { 2466 | $dataDirName = "backup" 2467 | $dataDirPath = $Script:BackupDir 2468 | } 2469 | 2470 | if ($Script:ErrorResult) { 2471 | $styleErrorInfo = $styleText 2472 | $styleErrorParagraph = $styleParagraph 2473 | } else { 2474 | $styleDataInfo = $styleText 2475 | $styleVarData = $styleVarText 2476 | $styleDataParagraph = $styleParagraph 2477 | } 2478 | 2479 | $emailTokens = @{ 2480 | STYLE_PAGE = $stylePage 2481 | STYLE_PARA = $styleParagraph 2482 | STYLE_TEXT = $styleText 2483 | STYLE_VAR_TEXT = $styleVarText 2484 | STYLE_ERROR = $styleErrorInfo 2485 | STYLE_ERROR_PARA = $styleErrorParagraph 2486 | STYLE_DATA = $styleDataInfo 2487 | STYLE_VAR_DATA = $styleVarData 2488 | STYLE_DATA_PARA = $styleDataParagraph 2489 | STYLE_RESULT = $styleResult 2490 | VALUE_BACKUP_MODE = $backupMode 2491 | VALUE_RESULT = $resultText 2492 | VALUE_COMPUTER_NAME = $env:ComputerName 2493 | VALUE_SCRIPT_PATH = $PSCommandPath 2494 | VALUE_DATA_DIR_NAME = $dataDirName 2495 | VALUE_DATA_DIR_PATH = $dataDirPath 2496 | VALUE_SCRIPT_START_TIME = $Script:StartTime 2497 | VALUE_SCRIPT_END_TIME = $Script:EndTime 2498 | VALUE_SCRIPT_DURATION = $Script:Duration 2499 | VALUE_ERROR_INFO = $Script:ErrorResult 2500 | VALUE_BACKUP_OBJECTS = $Script:ObjectCount 2501 | VALUE_BACKUP_SIZE = $Script:BackupSize 2502 | } 2503 | 2504 | # $htmlEmail = Get-Content -Path TemplateLetter.txt -RAW 2505 | foreach ($token in $emailTokens.GetEnumerator()) { 2506 | $pattern = '#{0}#' -f $token.key 2507 | $body = $body -replace $pattern, $token.Value 2508 | } 2509 | 2510 | WriteDebug "Exiting FormatEmail." 2511 | return $body 2512 | } 2513 | 2514 | #-------------------------------------------------------------------------- 2515 | # SendMail 2516 | # Sends email notification with the information about the operation result. 2517 | function SendMail { 2518 | [CmdletBinding()] 2519 | param ( 2520 | ) 2521 | WriteDebug "Entered SendMail." 2522 | 2523 | if (!$Script:From -or !$Script:To -or !$Script:SmtpServer) { 2524 | Write-Verbose "Undefined SMTP settings (SmtpServer, To, From)." 2525 | Write-Verbose "Email notification will not be sent." 2526 | } else { 2527 | $message = FormatEmail 2528 | 2529 | # WriteDebug $message 2530 | 2531 | [string]$subject = $null 2532 | 2533 | if ($Script:ErrorResult) { 2534 | $subject = $SUBJECT_ERROR 2535 | } else { 2536 | $subject = $SUBJECT_SUCCESS 2537 | } 2538 | 2539 | $params = @{} 2540 | 2541 | if ($Script:Port -gt 0) { 2542 | $params.Add("Port", $Script:Port) 2543 | } 2544 | 2545 | if ($Script:Credential) { 2546 | $params.Add("Credential", $Script:Credential) 2547 | } 2548 | 2549 | if (!($Script:NoSsl)) { 2550 | $params.Add("UseSsl", $true) 2551 | } 2552 | 2553 | if (MustSendAttachment) { 2554 | $params.Add("Attachment", @( $Script:LogFile )) 2555 | } 2556 | 2557 | Write-Verbose "Sending email notification to '$Script:To'." 2558 | 2559 | Send-MailMessage ` 2560 | @params ` 2561 | -From $Script:From ` 2562 | -To $Script:To ` 2563 | -Subject $subject ` 2564 | -Body $message ` 2565 | -BodyAsHtml ` 2566 | -SmtpServer $Script:SmtpServer 2567 | } 2568 | 2569 | WriteDebug "Exiting SendMail." 2570 | } 2571 | 2572 | #-------------------------------------------------------------------------- 2573 | # RobocopyFiles 2574 | # Copies the Plex app data folder using the Windows 'robocopy' tool. 2575 | function RobocopyFiles { 2576 | param ( 2577 | [string] 2578 | $source, 2579 | 2580 | [string] 2581 | $target, 2582 | 2583 | [string[]] 2584 | $excludeDirs, 2585 | 2586 | [string[]] 2587 | $excludeFiles, 2588 | 2589 | [int] 2590 | $retries, 2591 | 2592 | [int] 2593 | $retryWaitSec 2594 | ) 2595 | WriteDebug "Entered RobocopyFiles." 2596 | 2597 | # Build full paths to the excluded folders. 2598 | $excludePaths = $null 2599 | 2600 | $cmdArgs = [System.Collections.ArrayList]@( 2601 | "$source", 2602 | "$target", 2603 | "/MIR", 2604 | "/R:$retries", 2605 | "/W:$retryWaitSec", 2606 | "/MT" 2607 | ) 2608 | 2609 | try { 2610 | try { 2611 | # Set directories to exclude from backup. 2612 | if (($excludeDirs) -and ($excludeDirs.Count -gt 0)) { 2613 | $excludePaths = [System.Collections.ArrayList]@() 2614 | 2615 | Write-LogInfo "Excluding folders:" 2616 | 2617 | # We need to use full paths. 2618 | foreach ($excludeDir in $excludeDirs) { 2619 | Write-LogInfo $excludeDir -Indent 1 2620 | ($excludePaths.Add((Join-Path $source $excludeDir))) | Out-Null 2621 | } 2622 | 2623 | $cmdArgs.Add("/XD") | Out-Null 2624 | $cmdArgs.Add($excludePaths) | Out-Null 2625 | } 2626 | 2627 | # Set file types to exclude (e.g. '*.bif'). 2628 | if (($excludeFiles) -and ($excludeFiles.Count -gt 0)) { 2629 | Write-LogInfo "Excluding file types:" 2630 | 2631 | foreach ($excludeFile in $excludeFiles) { 2632 | Write-LogInfo $excludeFile -Indent 1 2633 | } 2634 | 2635 | $cmdArgs.Add("/XF") | Out-Null 2636 | $cmdArgs.Add($excludeFiles) | Out-Null 2637 | } 2638 | 2639 | if (!$Script:RawOutput) { 2640 | $sleepSec = 3 2641 | Write-LogWarning "This operation may take a long time." 2642 | Start-Sleep -Seconds $sleepSec 2643 | Write-LogWarning "A really long time." 2644 | Start-Sleep -Seconds $sleepSec 2645 | Write-LogWarning "Like hours..." 2646 | Start-Sleep -Seconds $sleepSec 2647 | Write-LogWarning "Seriously!" 2648 | Start-Sleep -Seconds $sleepSec 2649 | Write-LogWarning "There will be no feedback unless something goes wrong." 2650 | Start-Sleep -Seconds $sleepSec 2651 | Write-LogWarning "If you get worried, use Task Manager to check CPU/disk usage." 2652 | Start-Sleep -Seconds $sleepSec 2653 | Write-LogWarning "Otherwise, take a break and come back later." 2654 | } 2655 | 2656 | Write-LogInfo "Copying Plex app data files from:" 2657 | Write-LogInfo $source -Indent 1 2658 | Write-LogInfo "to:" 2659 | Write-LogInfo $target -Indent 1 2660 | Write-LogInfo "at:" 2661 | Write-LogInfo (GetTimestamp) -Indent 1 2662 | 2663 | if (!$Script:Test) { 2664 | if ($Script:Quiet -or (!$Script:RawOutput)) { 2665 | robocopy @cmdArgs *>&1 | Out-Null 2666 | } else { 2667 | robocopy @cmdArgs 2668 | } 2669 | } 2670 | } 2671 | catch { 2672 | throw (New-Object System.Exception( ` 2673 | "Error copying '$source' to '$target'. Robocopy failed.", ` 2674 | $_.Exception)) 2675 | } 2676 | 2677 | if ($LASTEXITCODE -gt 7) { 2678 | throw "Robocopy failed with error code $LASTEXITCODE. " + 2679 | "To troubleshoot, execute the following command: " + 2680 | "robocopy" + 2681 | (((ConvertTo-Json -InputObject $cmdArgs -Compress) -replace "[\[\],]", " ") ` 2682 | -replace "\s+"," ") 2683 | } 2684 | 2685 | Write-LogInfo "Completed at:" 2686 | Write-LogInfo (GetTimestamp) -Indent 1 2687 | } 2688 | finally { 2689 | WriteDebug "Exiting RobocopyFiles." 2690 | } 2691 | } 2692 | 2693 | #-------------------------------------------------------------------------- 2694 | # MoveFolder 2695 | # Moves contents of a folder using a ROBOCOPY command. 2696 | function MoveFolder { 2697 | param ( 2698 | [string] 2699 | $source, 2700 | 2701 | [string] 2702 | $target 2703 | ) 2704 | WriteDebug "Entered MoveFolder." 2705 | 2706 | if (!(Test-Path -Path $source -PathType Container)) { 2707 | Write-LogWarning "Folder not found:" 2708 | Write-LogWarning $source -Indent 1 2709 | Write-LogWarning "Skipping." 2710 | 2711 | WriteDebug "Exiting MoveFolder." 2712 | } 2713 | 2714 | Write-LogInfo "Moving:" 2715 | Write-LogInfo $source -Indent 1 2716 | Write-LogInfo "to:" 2717 | Write-LogInfo $target -Indent 1 2718 | Write-LogInfo "at:" 2719 | Write-LogInfo (GetTimestamp) -Indent 1 2720 | 2721 | try { 2722 | try { 2723 | if (!$Script:Test) { 2724 | if ($Script:Quiet -or (!$Script:RawOutput)) { 2725 | robocopy "$source" "$target" *.* /e /is /it *>&1 | Out-Null 2726 | } else { 2727 | robocopy "$source" "$target" *.* /e /is /it 2728 | } 2729 | } 2730 | } 2731 | catch { 2732 | throw (New-Object System.Exception( ` 2733 | "Robocopy failed.", $_.Exception)) 2734 | } 2735 | 2736 | if ($LASTEXITCODE -gt 7) { 2737 | throw "Robocopy failed with error code $LASTEXITCODE. " + 2738 | "To troubleshoot, execute the following command: " + 2739 | "robocopy '$source' '$target' *.* /e /is /it" 2740 | } 2741 | 2742 | Write-LogInfo "Completed at:" 2743 | Write-LogInfo (GetTimestamp) -Indent 1 2744 | } 2745 | finally { 2746 | WriteDebug "Exiting MoveFolder." 2747 | } 2748 | } 2749 | 2750 | #-------------------------------------------------------------------------- 2751 | # CopyFolder 2752 | # Moves contents of a folder using a ROBOCOPY command. 2753 | function CopyFolder { 2754 | param ( 2755 | [string] 2756 | $source, 2757 | 2758 | [string] 2759 | $target 2760 | ) 2761 | WriteDebug "Entered CopyFolder." 2762 | 2763 | try { 2764 | if (!(Test-Path -Path $source -PathType Container)) { 2765 | Write-LogWarning "Folder not found:" 2766 | Write-LogWarning $source -Indent 1 2767 | Write-LogWarning "Skipping." 2768 | 2769 | return 2770 | } 2771 | 2772 | Write-LogInfo "Copying:" 2773 | Write-LogInfo $source -Indent 1 2774 | Write-LogInfo "to:" 2775 | Write-LogInfo $target -Indent 1 2776 | Write-LogInfo "at:" 2777 | Write-LogInfo (GetTimestamp) -Indent 1 2778 | 2779 | try { 2780 | if (!$Script:Test) { 2781 | if ($Script:Quiet -or (!$Script:RawOutput)) { 2782 | robocopy "$source" "$target" *.* /e /is /it /move *>&1 | Out-Null 2783 | } else { 2784 | robocopy "$source" "$target" *.* /e /is /it /move 2785 | } 2786 | } 2787 | } 2788 | catch { 2789 | throw (New-Object System.Exception( ` 2790 | "Robocopy failed.", $_.Exception)) 2791 | } 2792 | 2793 | if ($LASTEXITCODE -gt 7) { 2794 | throw "Robocopy failed with error code $LASTEXITCODE. " + 2795 | "To troubleshoot, execute the following command: " 2796 | "robocopy '$source' '$target' *.* /e /is /it /move" 2797 | } 2798 | 2799 | Write-LogInfo "Completed at:" 2800 | Write-LogInfo (GetTimestamp) -Indent 1 2801 | } 2802 | finally { 2803 | WriteDebug "Exiting CopyFolder." 2804 | } 2805 | } 2806 | 2807 | #-------------------------------------------------------------------------- 2808 | # BackupSpecialFolders 2809 | # Moves contents of the special Plex app data folders to special backup 2810 | # folder to exclude them from the backup compression job, so that they 2811 | # could be copied separately (because special directories have long names, 2812 | # they can break archival process used by PowerShell's Compress-Archive 2813 | # command). 2814 | function BackupSpecialFolders { 2815 | param ( 2816 | [string[]] 2817 | $specialDirs, 2818 | 2819 | [string] 2820 | $plexAppDataDir, 2821 | 2822 | [string] 2823 | $backupDirPath 2824 | ) 2825 | WriteDebug "Entered BackupSpecialFolders." 2826 | 2827 | try { 2828 | if (!($specialDirs) -or 2829 | ($specialDirs.Count -eq 0)) { 2830 | return $true 2831 | } 2832 | 2833 | $i = 0 2834 | foreach ($specialDir in $specialDirs) { 2835 | $source = Join-Path $plexAppDataDir $specialDir 2836 | 2837 | if (!(Test-Path -Path $source -PathType Container)) { 2838 | continue 2839 | } 2840 | 2841 | $target = Join-Path $backupDirPath $specialDir 2842 | 2843 | try { 2844 | if ($i++ -eq 0) { 2845 | Write-LogInfo "Backing up special folders." 2846 | } 2847 | MoveFolder $source $target 2848 | } 2849 | catch { 2850 | throw (New-Object System.Exception( 2851 | "Error moving '$source' to '$tartget'.", $_.Exception)) 2852 | } 2853 | } 2854 | } 2855 | finally { 2856 | WriteDebug "Exiting BackupSpecialFolders." 2857 | } 2858 | } 2859 | 2860 | #-------------------------------------------------------------------------- 2861 | # RestoreSpecialFolders 2862 | # Copies backed up special Plex app data folders back to their original 2863 | # locations (see also BackupSpecialFolders). 2864 | function RestoreSpecialFolders { 2865 | param ( 2866 | [string[]] 2867 | $specialDirs, 2868 | 2869 | [string] 2870 | $plexAppDataDir, 2871 | 2872 | [string] 2873 | $backupDirPath 2874 | ) 2875 | WriteDebug "Entered RestoreSpecialFolders." 2876 | 2877 | try { 2878 | if ($specialDirs -and 2879 | $specialDirs.Count -gt 0) { 2880 | 2881 | $i = 0 2882 | foreach ($specialDir in $specialDirs) { 2883 | $source = Join-Path $backupDirPath $specialDir 2884 | 2885 | if (!(Test-Path -Path $source -PathType Container)) { 2886 | continue 2887 | } 2888 | 2889 | $target = Join-Path $plexAppDataDir $specialDir 2890 | 2891 | try { 2892 | if ($i++ -eq 0) { 2893 | Write-LogInfo "Restoring special folders." 2894 | } 2895 | 2896 | CopyFolder $source $target 2897 | } 2898 | catch { 2899 | throw (New-Object System.Exception( 2900 | "Error copying '$source' to '$tartget'.", $_.Exception)) 2901 | } 2902 | } 2903 | } 2904 | } 2905 | finally { 2906 | WriteDebug "Exiting RestoreSpecialFolders." 2907 | } 2908 | } 2909 | 2910 | #-------------------------------------------------------------------------- 2911 | # CompressFolder 2912 | # Backs up contents of a Plex app data subfolder to a compressed file. 2913 | function CompressFolder { 2914 | [CmdletBinding()] 2915 | param ( 2916 | [object] 2917 | $sourceDir, 2918 | 2919 | [string] 2920 | $backupDirPath 2921 | ) 2922 | WriteDebug "Entered CompressFolder." 2923 | 2924 | try { 2925 | $zipFileName = $sourceDir.Name + $Script:ZipFileExt 2926 | $zipFilePath = Join-Path $backupDirPath $zipFileName 2927 | 2928 | # Skip if ZIP file already exists. 2929 | if (Test-Path $zipFilePath -PathType Leaf) { 2930 | Write-LogWarning "Backup file:" 2931 | Write-LogWarning $zipFilePath -Indent 1 2932 | Write-LogWarning "already exists." 2933 | 2934 | if ($Script:Mode -eq $MODE_BACKUP) { 2935 | try { 2936 | Write-LogInfo "Deleting:" 2937 | Write-LogInfo $zipFilePath -Indent 1 2938 | 2939 | if (!$Script:Test) { 2940 | Remove-Item $zipFilePath -Force 2941 | } 2942 | } 2943 | catch { 2944 | throw (New-Object System.Exception( ` 2945 | "Cannot delete existing file '$zipFilePath'.", ` 2946 | $_.Exception)) 2947 | } 2948 | } 2949 | } 2950 | 2951 | if (!(Test-Path $zipFilePath -PathType Leaf)) { 2952 | # If a staged temp folder is specified (instead of compressing over network)... 2953 | if ($Script:TempDir) { 2954 | $tempZipFileName = $BACKUP_FILENAME + (New-Guid).Guid + $Script:ZipFileExt 2955 | $tempZipFilePath = Join-Path $Script:TempDir $tempZipFileName 2956 | } else { 2957 | # Use temp names for the final files. 2958 | $tempZipFileName = $zipFileName 2959 | $tempZipFilePath = $zipFilePath 2960 | } 2961 | 2962 | Write-LogInfo "Archiving:" 2963 | Write-LogInfo $sourceDir.FullName -Indent 1 2964 | Write-LogInfo "to:" 2965 | Write-LogInfo $tempZipFilePath -Indent 1 2966 | Write-LogInfo "at:" 2967 | Write-LogInfo (GetTimestamp) -Indent 1 2968 | 2969 | if ($Script:Type -eq $TYPE_7ZIP) { 2970 | [Array]$cmdArgs = "a", "$tempZipFilePath", (Join-Path $sourceDir.FullName "*"), "-r", "-y" 2971 | 2972 | if ($Script:ExcludeFiles -and $Script:ExcludeFiles.Count -gt 0) { 2973 | Write-Verbose "Excluding file types: $excludeFiles" 2974 | 2975 | foreach ($excludeFile in $Script:ExcludeFiles) { 2976 | if ($excludeFile) { 2977 | $cmdArgs += "-x!$excludeFile" 2978 | } 2979 | } 2980 | } 2981 | 2982 | if ($Script:ArchiverOptionsCompress -and $Script:ArchiverOptionsCompress.Count -gt 0) { 2983 | Write-Verbose "Setting 7-zip switches: $($Script:ArchiverOptionsCompress)" 2984 | 2985 | foreach ($option in $Script:ArchiverOptionsCompress) { 2986 | if ($option) { 2987 | $cmdArgs += $option 2988 | } 2989 | } 2990 | } 2991 | 2992 | if (!$Script:Test) { 2993 | if ($Script:Quiet -or (!$Script:RawOutput)) { 2994 | & $Script:ArchiverPath @cmdArgs *>&1 | Out-Null 2995 | } else { 2996 | & $Script:ArchiverPath @cmdArgs 2997 | } 2998 | } 2999 | 3000 | if ($LASTEXITCODE -gt 0) { 3001 | throw (Get7ZipError $LASTEXITCODE) 3002 | } 3003 | } else { 3004 | if (!$Script:Test) { 3005 | Compress-Archive -Path (Join-Path $sourceDir.FullName "*") ` 3006 | -DestinationPath $tempZipFilePath -Force 3007 | } 3008 | } 3009 | 3010 | Write-LogInfo "Completed at:" 3011 | Write-LogInfo (GetTimestamp) -Indent 1 3012 | 3013 | Write-LogInfo "Compressed file size:" 3014 | Write-LogInfo (FormatFileSize $tempZipFilePath) -Indent 1 3015 | 3016 | # When using temp folder, need to copy archived file to final destination. 3017 | if ($Script:TempDir) { 3018 | # If the folder was empty, the ZIP file will not be created. 3019 | if ((!$Script:Test) -and (!(Test-Path $tempZipFilePath -PathType Leaf))) { 3020 | Write-LogWarning "Temp archive file was not created." 3021 | } else { 3022 | # Copy temp ZIP file from the backup folder. 3023 | Write-LogInfo "Copying:" 3024 | Write-LogInfo $tempZipFilePath -Indent 1 3025 | Write-LogInfo "to:" 3026 | Write-LogInfo $zipFilePath -Indent 1 3027 | Write-LogInfo "at:" 3028 | Write-LogInfo (GetTimestamp) -Indent 1 3029 | 3030 | try { 3031 | if (!$Script:Test) { 3032 | Start-BitsTransfer -Source $tempZipFilePath ` 3033 | -Destination $zipFilePath -ErrorAction Stop 3034 | } 3035 | } 3036 | catch { 3037 | Write-LogError "Error copying:" 3038 | Write-LogError $tempZipFilePath -Indent 1 3039 | Write-LogError "to:" 3040 | Write-LogError $zipFilePath -Indent 1 3041 | WriteLogException $_ 3042 | 3043 | try { 3044 | if ((!$Script:Test) -and (Test-Path $tempZipFilePath -PathType Leaf)) { 3045 | Write-Verbose "Deleting '$tempZipFilePath'." 3046 | Remove-Item $tempZipFilePath -Force 3047 | } 3048 | } 3049 | catch { 3050 | Write-Verbose "Cannot delete '$tempZipFilePath'." 3051 | Write-Verbose (FormatError $_) 3052 | } 3053 | 3054 | throw "BitTransfer operation failed." 3055 | } 3056 | 3057 | Write-LogInfo "Completed at:" 3058 | Write-LogInfo (GetTimestamp) -Indent 1 3059 | 3060 | # Delete temp file. 3061 | if (Test-Path -Path $tempZipFilePath -PathType Leaf) { 3062 | try { 3063 | if ((!$Script:Test) -and (Test-Path $tempZipFilePath -PathType Leaf)) { 3064 | Write-Verbose "Deleting '$tempZipFilePath'." 3065 | Remove-Item $tempZipFilePath -Force 3066 | } 3067 | } 3068 | catch { 3069 | Write-Verbose "Cannot delete '$tempZipFilePath'." 3070 | Write-Verbose (FormatError $_) 3071 | 3072 | # Non-critical error; can continue. 3073 | $Error.Clear() 3074 | } 3075 | } 3076 | } 3077 | } 3078 | } 3079 | } 3080 | finally { 3081 | WriteDebug "Exiting CompressFolder." 3082 | } 3083 | } 3084 | 3085 | #-------------------------------------------------------------------------- 3086 | # CompressFiles 3087 | # Backs up contents of the Plex app data folder to the compressed files. 3088 | function CompressFiles { 3089 | [CmdletBinding()] 3090 | param ( 3091 | ) 3092 | WriteDebug "Entered CompressFiles." 3093 | 3094 | try { 3095 | # Build path to the ZIP file that will hold files from Plex app data folder. 3096 | $zipFileName = $BACKUP_FILENAME + $Script:ZipFileExt 3097 | $zipFileDir = Join-Path $Script:BackupDir $SUBDIR_FILES 3098 | $zipFilePath = Join-Path $zipFileDir $zipFileName 3099 | 3100 | # Back up files from root folder, if there are any. 3101 | if (@(Get-ChildItem (Join-Path $Script:PlexAppDataDir "*") -File ).Count -gt 0) { 3102 | 3103 | Write-LogInfo "Backing up Plex app data files in the root folder." 3104 | 3105 | # Delete existing backup file in the BACKUP mode. 3106 | if (Test-Path $zipFilePath -PathType Leaf) { 3107 | Write-LogWarning "Backup file:" 3108 | Write-LogWarning $zipFilePath -Indent 1 3109 | Write-LogWarning "already exists." 3110 | 3111 | if ($Script:Mode -eq $MODE_BACKUP) { 3112 | try { 3113 | Write-LogInfo "Deleting:" 3114 | Write-LogInfo $zipFilePath -Indent 1 3115 | 3116 | if (!$Script:Test) { 3117 | Remove-Item $zipFilePath -Force 3118 | } 3119 | } 3120 | catch { 3121 | throw (New-Object System.Exception( ` 3122 | "Cannot delete existing file '$zipFilePath'.", ` 3123 | $_.Exception)) 3124 | } 3125 | } 3126 | } 3127 | 3128 | # Only process if zip file is not there. 3129 | if (!(Test-Path $zipFilePath -PathType Leaf)) { 3130 | try { 3131 | if ($Script:Type -eq $TYPE_7ZIP) { 3132 | 3133 | # Set default arguments for compression. 3134 | [Array]$cmdArgs = "a", "$zipFilePath", "-y" 3135 | 3136 | # If we have a list of file types to exclude, set the appropriate argument. 3137 | if ($Script:ExcludeFiles -and $Script:ExcludeFiles.Count -gt 0) { 3138 | Write-Verbose "Excluding file types: $($Script:ExcludeFiles)" 3139 | 3140 | foreach ($excludeFile in $Script:ExcludeFiles) { 3141 | if ($excludeFile) { 3142 | $cmdArgs += "-x!$excludeFile" 3143 | } 3144 | } 3145 | } 3146 | 3147 | # Also, support user-provided command-line switches. 3148 | if ($Script:ArchiverOptionsCompress -and $Script:ArchiverOptionsCompress.Count -gt 0) { 3149 | Write-Verbose "Setting 7-zip switches: $($Script:ArchiverOptionsCompress)" 3150 | 3151 | foreach ($option in $Script:ArchiverOptionsCompress) { 3152 | if ($option) { 3153 | $cmdArgs += $option 3154 | } 3155 | } 3156 | } 3157 | if (!$Script:Test) { 3158 | if ($Script:Quiet -or (!$Script:RawOutput)) { 3159 | & $Script:ArchiverPath @cmdArgs (Get-ChildItem (Join-Path $Script:PlexAppDataDir "*") -File) *>&1 | Out-Null 3160 | } else { 3161 | & $Script:ArchiverPath @cmdArgs (Get-ChildItem (Join-Path $Script:PlexAppDataDir "*") -File) 3162 | } 3163 | } 3164 | 3165 | if ($LASTEXITCODE -gt 0) { 3166 | throw (Get7ZipError $LASTEXITCODE) 3167 | } 3168 | } else { 3169 | # Use the default compression. 3170 | if (!$Script:Test) { 3171 | Get-ChildItem -Path $Script:PlexAppDataDir -File | ` 3172 | Compress-Archive -DestinationPath '$zipFilePath' -Update 3173 | } 3174 | } 3175 | } 3176 | catch { 3177 | throw (New-Object System.Exception( ` 3178 | "Error compressing files in the Plex app data root folder.", ` 3179 | $_.Exception)) 3180 | } 3181 | 3182 | if (Test-Path $zipFilePath -PathType Leaf) { 3183 | Write-LogInfo "Completed at:" 3184 | Write-LogInfo (GetTimestamp) -Indent 1 3185 | 3186 | Write-LogInfo "Compressed file size:" 3187 | Write-LogInfo (FormatFileSize $zipFilePath) -Indent 1 3188 | } else { 3189 | Write-LogInfo "No files found in '$Script:PlexAppDataDir' (it's okay)." 3190 | } 3191 | } 3192 | } 3193 | 3194 | Write-LogInfo "Backing up Plex app data folders." 3195 | 3196 | $plexAppDataSubDirs = Get-ChildItem $Script:PlexAppDataDir -Directory | 3197 | Where-Object { $_.Name -notin $Script:ExcludeDirs } 3198 | 3199 | foreach ($plexAppDataSubDir in $plexAppDataSubDirs) { 3200 | try { 3201 | CompressFolder ` 3202 | $plexAppDataSubDir ` 3203 | (Join-Path $Script:BackupDir $SUBDIR_FOLDERS) 3204 | } 3205 | catch { 3206 | throw (New-Object System.Exception( ` 3207 | "Error compressing folder '$plexAppDataSubDir'.", $_.Exception)) 3208 | } 3209 | } 3210 | } 3211 | finally { 3212 | WriteDebug "Exiting CompressFiles." 3213 | } 3214 | } 3215 | 3216 | #-------------------------------------------------------------------------- 3217 | # PurgeOldBackups 3218 | # Deletes old backup folders. 3219 | function PurgeOldBackups { 3220 | [CmdletBinding()] 3221 | param ( 3222 | ) 3223 | WriteDebug "Entered PurgeOldBackups." 3224 | 3225 | # Delete old backup folders. 3226 | if ($Script:Keep -gt 0) { 3227 | 3228 | # Get all folders with names from newest to oldest. 3229 | # Do not include the backup directory if it already exists. 3230 | $oldBackupDirs = Get-ChildItem -Path $Script:BackupRootDir -Directory | 3231 | Where-Object { 3232 | $_.Name -match $REGEX_BACKUPDIRNAMEFORMAT -and 3233 | $_.Name -notmatch $Script:BackupDirName 3234 | } | Sort-Object -Descending 3235 | 3236 | $i = 1 3237 | 3238 | # Check if we got more backups than we need to keep. 3239 | if ($oldBackupDirs.Count -ge $Script:Keep) { 3240 | Write-LogInfo "Deleting old backup folder(s):" 3241 | } 3242 | 3243 | # Remove the oldest backup folders over the threshold. 3244 | foreach ($oldBackupDir in $oldBackupDirs) { 3245 | if ($i++ -ge $Script:Keep) { 3246 | Write-LogInfo $oldBackupDir.Name -Indent 1 3247 | 3248 | try { 3249 | if (!$Script:Test) { 3250 | Remove-Item $oldBackupDir.FullName -Force -Recurse 3251 | } 3252 | } 3253 | catch { 3254 | Write-LogError "Cannot delete folder '$($oldBackupDir.FullName)'." 3255 | WriteLogException $_ 3256 | 3257 | # Non-critical error; can continue. 3258 | $Error.Clear() 3259 | } 3260 | } 3261 | } 3262 | } 3263 | 3264 | WriteDebug "Exiting PurgeOldBackups." 3265 | } 3266 | 3267 | #-------------------------------------------------------------------------- 3268 | # SetUpBackupFolder 3269 | # Gets the backup folder ready. 3270 | function SetUpBackupFolder { 3271 | [CmdletBinding()] 3272 | param ( 3273 | ) 3274 | WriteDebug "Entered SetUpBackupFolder." 3275 | 3276 | try { 3277 | # Verify that temp folder exists (if specified). 3278 | if ($Script:Type -ne $TYPE_7ZIP) { 3279 | if ($Script:TempDir) { 3280 | # Make sure temp folder exists. 3281 | if (!(Test-Path -Path $Script:TempDir -PathType Container)) { 3282 | try { 3283 | Write-LogInfo "Creating temp archive folder:" 3284 | Write-LogInfo $Script:TempDir -Indent 1 3285 | 3286 | if (!$Script:Test) { 3287 | New-Item -Path $Script:TempDir -ItemType Directory -Force | Out-Null 3288 | } 3289 | } 3290 | catch { 3291 | throw (New-Object System.Exception( 3292 | "Cannot create temp archive folder '$Script:TempDir'.", 3293 | $_.Exception)) 3294 | } 3295 | } 3296 | 3297 | if (!$Script:Test) { 3298 | $tempFileMask = $BACKUP_FILENAME + "*$Script:ZipFileExt" 3299 | 3300 | Write-Verbose "Purging '$tempFileMask' from '$Script:TempDir'." 3301 | Get-ChildItem -Path $Script:TempDir -Include $tempFileMask | 3302 | Remove-Item -Force | Out-Null 3303 | } 3304 | } 3305 | } 3306 | 3307 | # Make sure that the backup parent folder exists. 3308 | if (!(Test-Path $Script:BackupRootDir -PathType Container)) { 3309 | try { 3310 | Write-LogInfo "Creating backup root folder:" 3311 | Write-LogInfo $Script:BackupRootDir -Indent 1 3312 | 3313 | if (!$Script:Test) { 3314 | New-Item -Path $Script:BackupRootDir -ItemType Directory -Force | Out-Null 3315 | } 3316 | 3317 | } 3318 | catch { 3319 | throw (New-Object System.Exception( ` 3320 | "Failed to create backup root folder '$Script:BackupRootDir'.", ` 3321 | $_.Exception)) 3322 | } 3323 | } 3324 | 3325 | # Verify that we got the backup root folder. 3326 | if (!(Test-Path $Script:BackupRootDir -PathType Container)) { 3327 | throw "Backup root folder '$Script:BackupRootDir' does not exist." 3328 | } 3329 | 3330 | # Build backup folder path. 3331 | Write-LogInfo "Backup will be saved in:" 3332 | Write-LogInfo $Script:BackupDir -Indent 1 3333 | 3334 | PurgeOldBackups 3335 | 3336 | if (!(Test-Path -Path $Script:BackupDir -PathType Container)) { 3337 | # Create new backup folder. 3338 | try { 3339 | Write-LogInfo "Creating backup folder:" 3340 | Write-LogInfo $Script:BackupDir -Indent 1 3341 | 3342 | if (!$Script:Test) { 3343 | New-Item -Path $Script:BackupDir -ItemType Directory -Force | Out-Null 3344 | } 3345 | } 3346 | catch { 3347 | throw (New-Object System.Exception( 3348 | "Cannot create backup folder '$Script:BackupDir'.", 3349 | $_.Exception)) 3350 | } 3351 | } 3352 | 3353 | # List of backup subfolders. 3354 | $subDirs = @($SUBDIR_FILES, $SUBDIR_FOLDERS, $SUBDIR_REGISTRY, $SUBDIR_SPECIAL) 3355 | 3356 | # For backup mode, let's clear the contents of the subfolders, if they exist. 3357 | if ($Script:Mode -eq $MODE_BACKUP) { 3358 | $printMsg = $false 3359 | foreach ($subDir in $subDirs) { 3360 | $subDirPath = Join-Path $Script:BackupDir $subDir 3361 | 3362 | # For backup mode, let's clear the contents of the subfolder, if it exists. 3363 | if (Test-Path -Path $subDirPath -PathType Container) { 3364 | if (!$printMsg) { 3365 | Write-LogInfo "Purging old backup files from task-specific subfolders in:" 3366 | Write-LogInfo $Script:BackupDir -Indent 1 3367 | 3368 | $printMsg = $true 3369 | } 3370 | 3371 | try { 3372 | Write-LogInfo $subDir -Indent 2 3373 | 3374 | if (!$Script:Test) { 3375 | Get-ChildItem -Path $subDirPath -Include * | Remove-Item -Recurse -Force | Out-Null 3376 | } 3377 | } 3378 | catch { 3379 | throw (New-Object System.Exception( 3380 | "Cannot purge old backup files from folder '$subDirPath'.", 3381 | $_.Exception)) 3382 | } 3383 | } 3384 | } 3385 | } 3386 | 3387 | # Create subfolders, if needed. 3388 | $printMsg = $false 3389 | foreach ($subDir in $subDirs) { 3390 | $subDirPath = Join-Path $Script:BackupDir $subDir 3391 | 3392 | if (!(Test-Path -Path $subDirPath -PathType Container)) { 3393 | if (!$printMsg) { 3394 | Write-LogInfo "Creating task-specific subfolders in:" 3395 | Write-LogInfo $Script:BackupDir -Indent 1 3396 | 3397 | $printMsg = $true 3398 | } 3399 | 3400 | try { 3401 | Write-LogInfo $subDir -Indent 2 3402 | 3403 | if (!$Script:Test) { 3404 | New-Item -Path $subDirPath -ItemType Directory -Force | Out-Null 3405 | } 3406 | } 3407 | catch { 3408 | throw (New-Object System.Exception( 3409 | "Cannot create folder '$subDirPath'.", 3410 | $_.Exception)) 3411 | } 3412 | } 3413 | } 3414 | } 3415 | finally { 3416 | WriteDebug "Exiting SetUpBackupFolder." 3417 | } 3418 | } 3419 | 3420 | #-------------------------------------------------------------------------- 3421 | # Backup 3422 | # Creates a backup for the Plex app data folder and registry key. 3423 | function Backup { 3424 | [CmdletBinding()] 3425 | param ( 3426 | ) 3427 | WriteDebug "Entered Backup." 3428 | 3429 | try { 3430 | # Just in case the backup folder is on a remote share, try waking it up. 3431 | WakeUpDir $Script:BackupRootDir 3432 | 3433 | SetUpBackupFolder 3434 | 3435 | # Save Plex version first in case we need to run in Continue mode later. 3436 | try { 3437 | SavePlexVersion 3438 | } 3439 | catch { 3440 | throw (New-Object System.Exception("Error saving Plex Media Server version.", $_.Exception)) 3441 | } 3442 | 3443 | if ($Script:Type -eq $TYPE_ROBOCOPY) { 3444 | RobocopyFiles ` 3445 | $Script:PlexAppDataDir ` 3446 | (Join-Path $Script:BackupDir $SUBDIR_FILES) ` 3447 | $Script:ExcludeDirs ` 3448 | $Script:ExcludeFiles ` 3449 | $Script:Retries ` 3450 | $Script:RetryWaitSec 3451 | } else { 3452 | try 3453 | { 3454 | # Temporarily move special subfolders to backup folder. 3455 | try { 3456 | BackupSpecialFolders ` 3457 | $Script:SpecialDirs ` 3458 | $Script:PlexAppDataDir ` 3459 | (Join-Path $Script:BackupDir $SUBDIR_SPECIAL) 3460 | } 3461 | catch { 3462 | throw (New-Object System.Exception( ` 3463 | "Error backing up special folders.", $_.Exception)) 3464 | } 3465 | 3466 | # Compress and archive Plex app data. 3467 | try { 3468 | CompressFiles 3469 | } 3470 | catch { 3471 | throw (New-Object System.Exception( ` 3472 | "Error compressing Plex app data files.", $_.Exception)) 3473 | } 3474 | } 3475 | finally { 3476 | try { 3477 | RestoreSpecialFolders ` 3478 | $Script:SpecialDirs ` 3479 | $Script:PlexAppDataDir ` 3480 | (Join-Path $Script:BackupDir $SUBDIR_SPECIAL) 3481 | } 3482 | catch { 3483 | Write-LogError "Error restoring special folders." 3484 | WriteLogException $_ 3485 | 3486 | $Error.Clear() 3487 | } 3488 | } 3489 | } 3490 | 3491 | # Export Plex registry keys. 3492 | foreach ($plexRegKey in $PLEX_REG_KEYS) { 3493 | if (!(Test-Path -Path $plexRegKey)) { 3494 | continue 3495 | } 3496 | 3497 | $plexRegKey = $plexRegKey.Replace(":", "") 3498 | $plexRegKeyFilePath = ((Join-Path (Join-Path $Script:BackupDir $SUBDIR_REGISTRY) ` 3499 | ((FormatRegFilename $plexRegKey) + $regFileExt))) 3500 | 3501 | Write-LogInfo "Exporting registry key:" 3502 | Write-LogInfo $plexRegKey -Indent 1 3503 | Write-LogInfo "to:" 3504 | Write-LogInfo $plexRegKeyFilePath -Indent 1 3505 | 3506 | try { 3507 | if (!$Script:Test) { 3508 | reg export $plexRegKey $plexRegKeyFilePath /y *>&1 | Out-Null 3509 | } 3510 | } 3511 | catch { 3512 | WriteLogException $_ 3513 | 3514 | # Non-critical error; can continue. 3515 | $Error.Clear() 3516 | } 3517 | 3518 | $i++ 3519 | } 3520 | 3521 | # Copy moved special subfolders back to original folders. 3522 | if ($Script:Type -ne $TYPE_ROBOCOPY) { 3523 | try { 3524 | RestoreSpecialFolders ` 3525 | $Script:SpecialDirs ` 3526 | $Script:PlexAppDataDir ` 3527 | (Join-Path $Script:BackupDir $SUBDIR_SPECIAL) 3528 | } 3529 | catch { 3530 | throw (New-Object System.Exception( ` 3531 | "Error restoring special folders after a successful backup.", ` 3532 | $_.Exception)) 3533 | } 3534 | } 3535 | } 3536 | finally { 3537 | WriteDebug "Exiting Backup." 3538 | } 3539 | } 3540 | 3541 | #-------------------------------------------------------------------------- 3542 | # DecompressFolder 3543 | # Restores a Plex app data folder from the corresponding compressed 3544 | # backup file. 3545 | function DecompressFolder { 3546 | [CmdletBinding()] 3547 | param ( 3548 | [object] 3549 | $zipFile 3550 | ) 3551 | WriteDebug "Entered DecompressFolder." 3552 | 3553 | try { 3554 | $zipFilePath = $zipFile.FullName 3555 | $plexAppDataDirName = $zipFile.BaseName 3556 | $plexAppDataDirPath = Join-Path $plexAppDataDir $plexAppDataDirName 3557 | 3558 | # If we have a temp folder, stage the extracting job. 3559 | if ($Script:TempDir) { 3560 | $tempZipFileName = $BACKUP_FILENAME + (New-Guid).Guid + $Script:ZipFileExt 3561 | $tempZipFilePath = Join-Path $Script:TempDir $tempZipFileName 3562 | 3563 | # Copy backup archive to a temp zip file. 3564 | Write-LogInfo "Copying:" 3565 | Write-LogInfo $zipFilePath -Indent 1 3566 | Write-LogInfo "to:" 3567 | Write-LogInfo $tempZipFilePath -Indent 1 3568 | Write-LogInfo "at:" 3569 | Write-LogInfo (GetTimestamp) -Indent 1 3570 | 3571 | try { 3572 | if (!$Script:Test) { 3573 | Start-BitsTransfer -Source $zipFilePath ` 3574 | -Destination $tempZipFilePath -ErrorAction Stop 3575 | } 3576 | } 3577 | catch { 3578 | throw (New-Object System.Exception( ` 3579 | "Error copying '$zipFilePath' to '$tempZipFilePath'.", ` 3580 | $_.Exception)) 3581 | } 3582 | 3583 | Write-LogInfo "Completed at:" 3584 | Write-LogInfo (GetTimestamp) -Indent 1 3585 | } else { 3586 | $tempZipFilePath = $zipFilePath 3587 | } 3588 | 3589 | Write-LogInfo "Restoring:" 3590 | Write-LogInfo $plexAppDataDirPath -Indent 1 3591 | Write-LogInfo "from:" 3592 | Write-LogInfo $tempZipFilePath -Indent 1 3593 | Write-LogInfo "at:" 3594 | Write-LogInfo (GetTimestamp) -Indent 1 3595 | 3596 | if ($Script:Type -eq $TYPE_7ZIP) { 3597 | [Array]$cmdArgs 3598 | 3599 | if ($Script:ArchiverOptionsExpand -and $Script:ArchiverOptionsExpand.Count -gt 0) { 3600 | Write-Verbose "Setting 7-zip switches: $($Script:ArchiverOptionsExpand)" 3601 | 3602 | foreach ($option in $Script:ArchiverOptionsExpand) { 3603 | if ($option) { 3604 | $cmdArgs += $option 3605 | } 3606 | } 3607 | } 3608 | 3609 | if (!$Script:Test) { 3610 | if ($Script:Quiet -or (!$Script:RawOutput)) { 3611 | & $Script:ArchiverPath "x" "$tempZipFilePath" "-o$plexAppDataDirPath" "-aoa" "-y" @cmdArgs *>&1 | Out-Null 3612 | } else { 3613 | & $Script:ArchiverPath "x" "$tempZipFilePath" "-o$plexAppDataDirPath" "-aoa" "-y" @cmdArgs 3614 | } 3615 | } 3616 | 3617 | if ($LASTEXITCODE -gt 0) { 3618 | throw (Get7ZipError $LASTEXITCODE) 3619 | } 3620 | } else { 3621 | if (!$Script:Test) { 3622 | Expand-Archive -Path $tempZipFilePath ` 3623 | -DestinationPath $plexAppDataDirPath -Force 3624 | } 3625 | } 3626 | 3627 | Write-LogInfo "Completed at:" 3628 | Write-LogInfo (GetTimestamp) -Indent 1 3629 | 3630 | # Delete temp file. 3631 | if ($Script:TempDir) { 3632 | Write-LogInfo "Deleting:" 3633 | Write-LogInfo $tempZipFilePath -Indent 1 3634 | 3635 | try { 3636 | if (!$Script:Test) { 3637 | Remove-Item $tempZipFilePath -Force 3638 | } 3639 | } 3640 | catch { 3641 | Write-LogError "Cannot delete temp file:" 3642 | Write-LogError $tempZipFilePath -Indent 1 3643 | WriteLogException $_ 3644 | 3645 | # Non-critical error; can continue. 3646 | $Error.Clear() 3647 | } 3648 | } 3649 | } 3650 | finally { 3651 | WriteDebug "Exiting DecompressFolder." 3652 | } 3653 | } 3654 | 3655 | #-------------------------------------------------------------------------- 3656 | # DecompressFiles 3657 | # Restores Plex app data folders from the compressed backup files. 3658 | function DecompressFiles { 3659 | [CmdletBinding()] 3660 | param ( 3661 | ) 3662 | WriteDebug "Entered DecompressFiles." 3663 | 3664 | try { 3665 | # Build path to the ZIP file that holds files from the root Plex app data folder. 3666 | $zipFileName = $BACKUP_FILENAME + $Script:ZipFileExt 3667 | $zipFileDir = Join-Path $Script:BackupDir $SUBDIR_FILES 3668 | $zipFilePath = Join-Path $zipFileDir $zipFileName 3669 | 3670 | # Restore files in the root Plex app data folder. 3671 | if (Test-Path -Path $zipFilePath -PathType Leaf) { 3672 | Write-LogInfo "Restoring Plex app data files in the root folder." 3673 | 3674 | try { 3675 | if ($Script:Type -eq $TYPE_7ZIP) { 3676 | [Array]$cmdArgs 3677 | 3678 | if ($Script:ArchiverOptionsExpand -and $Script:ArchiverOptionsExpand.Count -gt 0) { 3679 | Write-Verbose "Setting 7-zip switches: $($Script:ArchiverOptionsExpand)" 3680 | 3681 | foreach ($option in $Script:ArchiverOptionsExpand) { 3682 | if ($option) { 3683 | $cmdArgs += $option 3684 | } 3685 | } 3686 | } 3687 | 3688 | if (!$Script:Test) { 3689 | if ($Script:Quiet -or (!$Script:RawOutput)) { 3690 | & $Script:ArchiverPath "x" "$zipFilePath" "-o$Script:PlexAppDataDir" "-y" @cmdArgs *>&1 | Out-Null 3691 | } else { 3692 | & $Script:ArchiverPath "x" "$zipFilePath" "-o$Script:PlexAppDataDir" "-y" @cmdArgs 3693 | } 3694 | } 3695 | 3696 | if ($LASTEXITCODE -gt 0) { 3697 | throw (Get7ZipError $LASTEXITCODE) 3698 | } 3699 | } else { 3700 | if (!$Script:Test) { 3701 | Expand-Archive -Path $zipFilePath -DestinationPath $Script:PlexAppDataDir -Force 3702 | } 3703 | } 3704 | } 3705 | catch { 3706 | throw (New-Object System.Exception( ` 3707 | "Error decompressing files in the Plex app data root folder.", ` 3708 | $_.Exception)) 3709 | } 3710 | } 3711 | 3712 | Write-LogInfo "Restoring Plex app data folders." 3713 | 3714 | $foldersSubDirPath = Join-Path $Script:BackupDir $SUBDIR_FOLDERS 3715 | $zipFiles = Get-ChildItem $foldersSubDirPath -File | Where-Object { $_.Extension -eq $Script:ZipFileExt } 3716 | 3717 | if ($zipFiles.Count -eq 0) { 3718 | throw "No compressed files with extension '$Script:ZipFileExt' found in the backup folder '$foldersSubDirPath'." 3719 | } 3720 | 3721 | # Restore each Plex app data subfolder from the corresponding backup archive file. 3722 | foreach ($zipFile in $zipFiles) { 3723 | try { 3724 | DecompressFolder $zipFile 3725 | } 3726 | catch { 3727 | throw (New-Object System.Exception( ` 3728 | "Error decompressing file '$zipFile'.", $_.Exception)) 3729 | } 3730 | } 3731 | } 3732 | finally { 3733 | WriteDebug "Exiting CompressFiles." 3734 | } 3735 | } 3736 | 3737 | #-------------------------------------------------------------------------- 3738 | # Restore 3739 | # Restores Plex app data and registry key from a backup. 3740 | function Restore { 3741 | param ( 3742 | ) 3743 | WriteDebug "Entered Restore." 3744 | 3745 | try { 3746 | if ($Script:Type -eq $TYPE_ROBOCOPY) { 3747 | RobocopyFiles ` 3748 | (Join-Path $Script:BackupDir $SUBDIR_FILES) ` 3749 | $Script:PlexAppDataDir ` 3750 | $null ` 3751 | $null ` 3752 | $Script:Retries ` 3753 | $Script:RetryWaitSec 3754 | } else { 3755 | # Compress and archive Plex app data. 3756 | try { 3757 | DecompressFiles 3758 | } 3759 | catch { 3760 | throw (New-Object System.Exception( ` 3761 | "Error decompressing Plex app data files.", $_.Exception)) 3762 | } 3763 | 3764 | # Restore special subfolders. 3765 | try { 3766 | RestoreSpecialFolders ` 3767 | $Script:SpecialDirs ` 3768 | $Script:PlexAppDataDir ` 3769 | (Join-Path $Script:BackupDir $subDirSpecial) 3770 | } 3771 | catch { 3772 | throw (New-Object System.Exception( ` 3773 | "Error restoring special folders.", $_.Exception)) 3774 | } 3775 | } 3776 | 3777 | # Import Plex registry keys. 3778 | $backupRegKeyFiles = Get-ChildItem ` 3779 | (Join-Path $Script:BackupDir $SUBDIR_REGISTRY) -File | 3780 | Where-Object { $_.Extension -eq $regFileExt } 3781 | 3782 | # Restore each Plex app data subfolder from the corresponding backup archive file. 3783 | $i = 0 3784 | foreach ($backupRegKeyFile in $backupRegKeyFiles) { 3785 | $backupRegKeyFilePath = $backupRegKeyFile.FullName 3786 | 3787 | if ($i++ -eq 0) { 3788 | Write-LogInfo "Importing registry key file:" 3789 | } 3790 | Write-LogInfo $backupRegKeyFilePath -Indent 1 3791 | 3792 | try { 3793 | if (!$Script:Test) { 3794 | # https://stackoverflow.com/questions/61483349/powershell-throws-terminating-error-on-reg-import-but-operation-completes-succes 3795 | $process = Start-Process reg -ArgumentList "import `"$backupRegKeyFilePath`"" -PassThru -Wait 3796 | 3797 | if ($process.ExitCode -ne 0) { 3798 | throw "Process 'reg import' returned $($process.ExitCode)." 3799 | } 3800 | } 3801 | } 3802 | catch { 3803 | # This is a bogus error that only appears in PowerShell ISE, so ignore. 3804 | #if (-not ($_.Exception -and $_.Exception.Message -and 3805 | # ($_.Exception.Message -match "^The operation completed successfully"))) { 3806 | throw (New-Object System.Exception( ` 3807 | "Error importing '$backupRegKeyFilePath'.", $_.Exception)) 3808 | #} 3809 | } 3810 | } 3811 | } 3812 | finally { 3813 | WriteDebug "Exiting Restore." 3814 | } 3815 | } 3816 | 3817 | #-------------------------------------------------------------------------- 3818 | # ProcessBackup 3819 | # Implements the backup operations. 3820 | function ProcessBackup { 3821 | [CmdletBinding()] 3822 | param( 3823 | ) 3824 | WriteDebug "Entered ProcessBackup." 3825 | 3826 | try { 3827 | Write-LogInfo "Operation mode:" 3828 | $mode = $Script:Mode.ToUpper() 3829 | if ($Script:Test) { 3830 | $mode += " (TEST)" 3831 | } 3832 | Write-LogInfo $mode.ToUpper() -Indent 1 3833 | 3834 | Write-LogInfo "Backup type:" 3835 | if ($Script:Type) { 3836 | Write-LogInfo $Script:Type.ToUpper() -Indent 1 3837 | } else { 3838 | Write-LogInfo "DEFAULT" -Indent 1 3839 | } 3840 | 3841 | if ($Script:PlexVersion) { 3842 | Write-LogInfo "Plex version:" 3843 | Write-LogInfo $Script:PlexVersion -Indent 1 3844 | } 3845 | 3846 | if ($Script:BackupVersion) { 3847 | Write-LogInfo "Backup version:" 3848 | Write-LogInfo $Script:BackupVersion -Indent 1 3849 | } 3850 | 3851 | if ($Script:LogFile) { 3852 | Write-LogInfo "Log file:" 3853 | Write-LogInfo $Script:LogFile -Indent 1 3854 | } 3855 | 3856 | if ($Script:ErrorLogFile) { 3857 | Write-LogInfo "Error log file:" 3858 | Write-LogInfo $Script:ErrorLogFile -Indent 1 3859 | } 3860 | 3861 | [object[]]$plexServices = $null 3862 | 3863 | $success = $false 3864 | 3865 | try { 3866 | # Get list of all running Plex services (match by display name). 3867 | try { 3868 | $plexServices = GetPlexServices 3869 | } 3870 | catch { 3871 | throw (New-Object System.Exception( ` 3872 | "Error enumerating Plex Windows services.", $_.Exception)) 3873 | } 3874 | 3875 | # Stop all running Plex services and Plex process. 3876 | $success, $plexServices = StopPlexServices $plexServices 3877 | 3878 | if (!$success) { 3879 | throw "Error stopping Plex Windows services." 3880 | } 3881 | 3882 | $success = $false 3883 | 3884 | StopPlexMediaServer 3885 | 3886 | if ($Script:Mode -eq $MODE_RESTORE) { 3887 | Restore 3888 | } else { 3889 | Backup 3890 | } 3891 | 3892 | $success = $true 3893 | 3894 | try { 3895 | [string]$dir = $null 3896 | 3897 | if ($Script:Mode -eq $MODE_RESTORE) { 3898 | $dir = $Script:PlexAppDataDir 3899 | } else { 3900 | $dir = $Script:BackupDir 3901 | } 3902 | 3903 | $backupInfo = Get-ChildItem -Recurse -File $dir | Measure-Object -Property Length -Sum 3904 | 3905 | $Script:ObjectCount = "{0:N0}" -f $backupInfo.Count 3906 | $Script:BackupSize = ([math]::round($backupInfo.Sum /1Gb, 1)).ToString() 3907 | } 3908 | catch { 3909 | Write-LogError "Error calculating backup statistics." 3910 | Write-LogException $_ 3911 | $Error.Clear() 3912 | } 3913 | 3914 | try { 3915 | # Log off all currently logged on users except the current user. 3916 | if ($Script:Logoff) { 3917 | 3918 | Write-LogInfo "Logging off all other users connected to this computer." 3919 | 3920 | if (!$Script:Test) { 3921 | quser | Select-String "Disc" | ForEach-Object {logoff ($_.ToString() -split ' +')[2]} 3922 | } 3923 | } 3924 | } 3925 | catch { 3926 | Write-Error "Attempt to log off other users connected to this computer failed." 3927 | WriteLogException $_ 3928 | 3929 | $Error.Clear() 3930 | } 3931 | 3932 | # Wake up a NAS share if needed. 3933 | try { 3934 | if ($Script:WakeUpDir) { 3935 | Write-LogInfo "Waking up '$Script:WakeUpDir'." 3936 | WakeUpDir $Script:WakeUpDir 3937 | } 3938 | } 3939 | catch { 3940 | Write-Error "Attempt to wake up '$Script:WakeUpDir' failed." 3941 | WriteLogException $_ 3942 | 3943 | $Error.Clear() 3944 | } 3945 | 3946 | if ($Script:Mode -eq $MODE_RESTORE) { 3947 | Write-LogInfo "Restore operation completed." 3948 | } else { 3949 | Write-LogInfo "Backup operation completed." 3950 | } 3951 | 3952 | $success = $true 3953 | } 3954 | catch { 3955 | WriteLogException $_ 3956 | 3957 | $Script:ErrorResult = FormatError $_ 3958 | 3959 | $Error.Clear() 3960 | } 3961 | finally { 3962 | $Script:EndTime = Get-Date 3963 | $Script:Duration = (New-TimeSpan -Start $Script:StartTime ` 3964 | -End $Script:EndTime).ToString("hh\:mm\:ss\.fff") 3965 | 3966 | # Resturn Plex unless instructed to do otherwise 3967 | # (do not restart after failed restore). 3968 | if ((!$Script:NoRestart) -and 3969 | ($success -or $Script:Mode -ne $MODE_RESTORE)) { 3970 | StartPlexServices $plexServices 3971 | StartPlexMediaServer 3972 | } 3973 | } 3974 | } 3975 | finally { 3976 | WriteDebug "Exiting ProcessBackup." 3977 | } 3978 | } 3979 | 3980 | #-------------------------------[ PROGRAM ]-------------------------------- 3981 | 3982 | # We will trap errors in the try-catch blocks. 3983 | $ErrorActionPreference = 'Stop' 3984 | 3985 | # Make sure we have no pending errors. 3986 | $Error.Clear() 3987 | $LASTEXITCODE = 0 3988 | 3989 | # Assume that SMTP server does not require authentication for now. 3990 | [System.Management.Automation.PSCredential]$credential = $null 3991 | 3992 | WriteDebug "Entered Main." 3993 | 3994 | try { 3995 | try { 3996 | $psVersion = GetPowerShellversion 3997 | Write-Verbose "Microsoft PowerShell v$psVersion" 3998 | 3999 | # Add custom folder(s) to the module path. 4000 | SetModulePath 4001 | 4002 | # Load module dependencies. 4003 | LoadModules $MODULES 4004 | } 4005 | catch { 4006 | throw (New-Object System.Exception( ` 4007 | "Error processing dependencies.", $_.Exception)) 4008 | } 4009 | 4010 | # Load settings from a config file (if any). 4011 | $configFileName = $null 4012 | 4013 | try { 4014 | if ($configFile) { 4015 | $configFileName = "configuration file '$configFile'" 4016 | } else { 4017 | $configFileName = "default configuration file (if any)" 4018 | } 4019 | 4020 | Write-Verbose "Importing settings from $configFileName." 4021 | Import-ConfigFile -ConfigFilePath $configFile ` 4022 | -DefaultParameters $PSBoundParameters 4023 | } 4024 | catch { 4025 | throw (New-Object System.Exception( ` 4026 | "Cannot import run-time settings from $configFileName.", ` 4027 | $_.Exception)) 4028 | } 4029 | 4030 | # Initialize globals. 4031 | try { 4032 | # Set up global variables. 4033 | InitGlobals 4034 | 4035 | # Set up mail configuration. 4036 | InitMail 4037 | } 4038 | catch { 4039 | throw (New-Object System.Exception( ` 4040 | "Initialization error.", $_.Exception)) 4041 | } 4042 | 4043 | # Validate things that need to be validated. 4044 | try { 4045 | # Make sure we have all required inputs. 4046 | ValidateData 4047 | 4048 | # For 7-zip backup type, make sure the 7-zip command-line tool is valid. 4049 | Validate7Zip 4050 | 4051 | # Make sure the version is okay (for restore operation). 4052 | ValidateVersion 4053 | 4054 | # Make sure script is not already running (unless we ignore single instance check). 4055 | ValidateSingleInstance 4056 | } 4057 | catch { 4058 | throw (New-Object System.Exception("Validation error.", $_.Exception)) 4059 | } 4060 | 4061 | # Initialize logging. 4062 | try { 4063 | StartLogging 4064 | } 4065 | catch { 4066 | throw (New-Object System.Exception( ` 4067 | "Logging error.", $_.Exception)) 4068 | } 4069 | 4070 | # PRE-MAIN LOGIC. 4071 | Prologue 4072 | 4073 | # Perform backup. 4074 | ProcessBackup 4075 | 4076 | # POST-MAIN LOGIC. 4077 | Epilogue 4078 | 4079 | if (!$Script:ErrorResult) { 4080 | $Script:ExitCode = $EXITCODE_SUCCESS 4081 | } 4082 | } 4083 | # Unhandled exception handler. 4084 | catch { 4085 | if (!$Script:ErrorResult) { 4086 | $Script:ErrorResult = FormatError $_ 4087 | } 4088 | 4089 | # Set end time if needed. 4090 | while ($Script:EndTime -eq $Script:StartTime) { 4091 | Start-Sleep -Milliseconds 10 4092 | $Script:EndTime = Get-Date 4093 | } 4094 | 4095 | # Depending on whether logs were initialized, print error. 4096 | if (Test-LoggingStarted) { 4097 | # Print error to logs. 4098 | Write-LogError $_ 4099 | } else { 4100 | # Print error to screen. 4101 | WriteException $_ 4102 | } 4103 | } 4104 | finally { 4105 | # Close mutex enforcing single-instance operation. 4106 | if (Get-Module -Name $MODULE_SingleInstance) { 4107 | Exit-SingleInstance 4108 | } else { 4109 | Write-Verbose "$MODULE_SingleInstance module is not loaded." 4110 | } 4111 | 4112 | # We may need to dispose logging resources (i.e. stream writers). 4113 | StopLogging 4114 | 4115 | try { 4116 | # Send email notification if needed. 4117 | if (MustSendMail ($Script:ExitCode -eq $EXITCODE_SUCCESS)) { 4118 | try { 4119 | SendMail 4120 | } 4121 | catch { 4122 | WriteError "Failed to send email notification." 4123 | WriteException $_ 4124 | $Error.Clear() 4125 | } 4126 | } 4127 | 4128 | # Reboot only on success and not in restore mode. 4129 | if (($Script:Reboot -or $Script:ForceReboot) -and 4130 | $Script:ExitCode -eq $EXITCODE_SUCCESS -and 4131 | $Script:Mode -ne $MODE_RESTORE) { 4132 | 4133 | Write-Verbose "Rebooting computer." 4134 | 4135 | if ($Script:ForceReboot) { 4136 | # If we are still here, reboot computer immediately. 4137 | if (!$Script:Test) { 4138 | Restart-Computer -Force 4139 | } 4140 | } else { 4141 | if (!$Script:Test) { 4142 | Restart-Computer 4143 | } 4144 | } 4145 | } 4146 | } 4147 | catch { 4148 | WriteException $_ 4149 | $Error.Clear() 4150 | } 4151 | finally { 4152 | WriteDebug "Exiting Main." 4153 | } 4154 | 4155 | exit $Script:ExitCode 4156 | } 4157 | 4158 | # THE END 4159 | #-------------------------------------------------------------------------- 4160 | -------------------------------------------------------------------------------- /PlexBackup.ps1.SAMPLE.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "version": "1.0", 4 | "strict": false, 5 | "description": "Sample run-time settings for the PlexBackup.ps1 script." 6 | }, 7 | "Mode": { 8 | "_meta": { 9 | "set": "Backup,Continue,Restore", 10 | "default": "Backup" 11 | }, 12 | "value": null 13 | }, 14 | "Type": { 15 | "_meta": { 16 | "set": ",7zip,Robocopy", 17 | "default": "" 18 | }, 19 | "value": null 20 | }, 21 | "PlexAppDataDir": { 22 | "_meta": { 23 | "default": "$env:LOCALAPPDATA\\Plex Media Server" 24 | }, 25 | "value": null 26 | }, 27 | "BackupRootDir": { 28 | "_meta": { 29 | "default": "$PSScriptRoot" 30 | }, 31 | "value": null 32 | }, 33 | "BackupDir": { 34 | "_meta": { 35 | "default": null 36 | }, 37 | "value": null 38 | }, 39 | "TempDir": { 40 | "_meta": { 41 | "default": "$env:TEMP" 42 | }, 43 | "hasValue": true, 44 | "value": null 45 | }, 46 | "WakeUpDir": { 47 | "_meta": { 48 | "default": null 49 | }, 50 | "value": null 51 | }, 52 | "ArchiverPath": { 53 | "_meta": { 54 | "default": "$env:ProgramFiles\\7-Zip\\7z.exe" 55 | }, 56 | "value": null 57 | }, 58 | "Quiet": { 59 | "value": null 60 | }, 61 | "LogLevel": { 62 | "_meta": { 63 | "default": "None,Error,Warning,Info,Debug" 64 | }, 65 | "value": null 66 | }, 67 | "Log": { 68 | "value": true 69 | }, 70 | "LogFile": { 71 | "value": null 72 | }, 73 | "ErrorLog": { 74 | "value": null 75 | }, 76 | "ErrorLogFile": { 77 | "value": null 78 | }, 79 | "Keep": { 80 | "_meta": { 81 | "range": "0-[int]::MaxValue", 82 | "default": "3" 83 | }, 84 | "value": null 85 | }, 86 | "Retries": { 87 | "_meta": { 88 | "range": "0-[int]::MaxValue", 89 | "default": "5" 90 | }, 91 | "value": null 92 | }, 93 | "RetryWaitSec": { 94 | "_meta": { 95 | "range": "0-[int]::MaxValue", 96 | "default": "10" 97 | }, 98 | "value": null 99 | }, 100 | "RawOutput": { 101 | "value": null 102 | }, 103 | "Inactive": { 104 | "value": null 105 | }, 106 | "NoRestart": { 107 | "value": null 108 | }, 109 | "NoSingleton": { 110 | "value": null 111 | }, 112 | "NoVersion": { 113 | "value": null 114 | }, 115 | "NoLogo": { 116 | "value": null 117 | }, 118 | "Test": { 119 | "value": false 120 | }, 121 | "SendMail": { 122 | "_meta": { 123 | "set": "Never,Always,OnError,OnSuccess,OnBackup,OnBackupError,OnBackupSuccess,OnRestore,OnRestoreError,OnRestoreSuccess", 124 | "default": "Never" 125 | }, 126 | "value": null 127 | }, 128 | "SmtpServer": { 129 | "value": "smtp.gmail.com" 130 | }, 131 | "Port": { 132 | "_meta": { 133 | "range": "0-[int]::MaxValue", 134 | "default": "0" 135 | }, 136 | "value": 587 137 | }, 138 | "From": { 139 | "value": null 140 | }, 141 | "To": { 142 | "value": null 143 | }, 144 | "NoSsl": { 145 | "value": null 146 | }, 147 | "CredentialFile": { 148 | "value": null 149 | }, 150 | "NoCredential": { 151 | "value": null 152 | }, 153 | "Anonymous": { 154 | "value": null 155 | }, 156 | "SendLogFile": { 157 | "_meta": { 158 | "set": "Never,OnError,OnSuccess,Always", 159 | "default": "Never" 160 | }, 161 | "value": "OnError" 162 | }, 163 | "Logoff": { 164 | "value": null 165 | }, 166 | "Reboot": { 167 | "value": null 168 | }, 169 | "ForceReboot": { 170 | "value": null 171 | }, 172 | "ExcludeDirs": { 173 | "_meta": { 174 | "default": ["Diagnostics","Crash Reports","Updates","Logs"] 175 | }, 176 | "value": null 177 | }, 178 | "ExcludeFiles": { 179 | "_meta": { 180 | "default": ["*.bif"] 181 | }, 182 | "value": null 183 | }, 184 | "SpecialDirs": { 185 | "_meta": { 186 | "default": ["Plug-in Support\\Data\\com.plexapp.system\\DataItems\\Deactivated"] 187 | }, 188 | "value": null 189 | }, 190 | "PlexServiceName": { 191 | "_meta": { 192 | "default": "^Plex" 193 | }, 194 | "hasValue": false, 195 | "value": null 196 | }, 197 | "PlexServerFileName": { 198 | "_meta": { 199 | "default": "Plex Media Server.exe" 200 | }, 201 | "value": null 202 | }, 203 | "PlexServerPath": { 204 | "value": null 205 | }, 206 | "ArchiverOptionsCompress": { 207 | "_meta": { 208 | "comment": "The default options will always be applied. To include additional options, define them as an array.", 209 | "default": ["-r","-y"] 210 | }, 211 | "value": null 212 | }, 213 | "ArchiverOptionsExpand": { 214 | "_meta": { 215 | "comment": "The default options will always be applied. To include additional options, define them as an array.", 216 | "default": ["-aoa","-y"] 217 | }, 218 | "value": null 219 | }, 220 | "Machine": { 221 | "_meta": { 222 | "set": "x86,amd64", 223 | "default": null 224 | }, 225 | "value": null 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlexBackup.ps1 2 | [PlexBackup.ps1](PlexBackup.ps1) is a [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/overview) script that can back up and restore [Plex](https://www.plex.tv/) application data files on a Windows system. This document explains how it works and how to use it. If you don't care about the details and just want to get the instruction, see [Getting Started](GETTING%20STARTED.md). 3 | 4 | ## Introduction 5 | Plex does not offer a meaningful backup feature. Yes, it can back up a Plex database which can be handy in case of a Plex database corruption, but if you need to move your Plex instance to a different system or restore it after a hard drive crash, a single database backup will be of little use. For a meaningful backup, in addition to the Plex database, you will need a copy of the Plex Windows registry key and tens (or hundreds) of thousands of Plex application data files. Keep in mind that backing up gigabytes of data files may take a long time, especially when they are copied remotely, such as to a NAS share. And you must keep the Plex Media Server stopped while the backup job is running (otherwise, it can corrupt the data). In the other words, a meaningful Plex backup is a challenge to which I could not find a good solution, so I decided to build my own. Ladies and gentlemen, meet [PlexBackup.ps1](PlexBackup.ps1). 6 | 7 | ## Overview 8 | [PlexBackup.ps1](PlexBackup.ps1) (or, briefly, _PlexBackup_) is a [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/overview) script that can back up and restore a Plex instance on a Windows system. The script backs up the Plex database, Windows registry key, and app data folders essential for the Plex operations (it ignores the non-essential files, such as logs or crash reports). And it makes sure that Plex is not running when the backup job is active (which may take hours). And it can send email to you, too. 9 | 10 | IMPORTANT: The script will not back up media (video, audio, images) or Plex program files. You must use different backup techniques for those. For example, you can keep your media files on a [RAID 5](https://en.wikipedia.org/wiki/Standard_RAID_levels#RAID_5) disk array (for redundancy) and back them up to an alternative storage on a periodic basis. And you don't really need to back up Plex program files, since you can always download them. But for everything else, PlexBackup is your guy. 11 | 12 | ### Backup types 13 | The script can perform two types of backup: compressed (default or `7zip`) and uncompressed (`Robocopy`). 14 | 15 | #### Default 16 | By default, the script compresses every essential folder under the root of the Plex application data folder (`Plex Media Server`) and saves the compressed data as separate ZIP files. For better efficiency (in case the backup folder is remote, such as on a NAS share), it first compresses the data in a temporary local file and then moves the compressed file to a backup folder (you can compress the data and save the ZIP files directly to the backup folder by setting the value of the `TempDir` parameter to null or empty string). There is a problem with PowerShell's compression routine that fails to process files and folders with paths that are longer than 260 characters. If you get this error, use the `SpecialDirs` parameter (in code or config file) to specify the folders that are too long (or parents of the subfolders or files that are too long) and PlexBackup will copy them as-is without using compression (by default, the following application data folder is not compressed: `Plug-in Support\Data\com.plexapp.system\DataItems\Deactivated`). 17 | 18 | #### 7zip 19 | Instead of the default compression, you can use the [7-zip](https://www.7-zip.org/) command-line tool (`7z.exe`). 7-zip works faster (for both compression and extraction), it produces smaller compressed files, and it allowes you to exclude specific file types using the `ExcludeFiles` parameter (by default, it excludes `*.bif`, i.e. thumbnail preview files). To use 7-zip compression, [install 7-zip](https://www.7-zip.org/download.html), and set the PlexBackup script's `Type` parameter to `7zip` (i.e. `-Type 7zip`) or use the `-SevenZip` shortcut (on command line). If you install 7-zip in a non-default directory, use the `ArchiverPath` parameter to set path to the `7z.exe` file. 20 | 21 | #### Robocopy 22 | If you run PlexBackup with the `Robocopy` switch, instead of compression, the script will create a mirror of the Plex application data folder (minus the non-essential folders) using the [Robocopy](https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/robocopy) command (executed with the `/MIR` switch). Robocopy also allows you to exclude specific file types as [described above](#7zip). 23 | 24 | You may want to play with either option to see which one works better for you. 25 | 26 | ### Backup modes 27 | PlexBackup can run in the following modes (specified by the `Mode` switch or a corresponding shortcut): 28 | 29 | - `Backup`: the default mode that creates a new backup archive. 30 | - `Continue`: resumes an incomplete backup job or, in case of the `Robocopy` backup, re-syncs the backup archive. 31 | - `Restore`: restores Plex application data from a backup. 32 | 33 | If a previous backup does not exist, the `Continue` mode will behave just like the `Backup` mode. When running in the `Continue` mode for backup jobs that use compression, the script will skip the folders that already have the corresponding archive files. For the `Robocopy` backup, the `Continue` mode will synchronize the existing backup archive with the Plex application data files. 34 | 35 | In all cases, before performing a backup or restore operation, PlexBackup will stop all Plex Windows services along with the Plex Media Server process. After the script completes the operation, it will restart them. You can use the `NoRestart` switch to tell the script not to restart the Plex Media Server process. The script will not run if the Plex Media Server is not active. To execute the script when Plex Media Server is not running, use the `Inactive` switch, but make sure you know what you are doing. 36 | 37 | ### Plex Windows Registry key 38 | To make sure PlexBackup saves and restores the right registry key, run it under the same account as Plex Media Server runs. The registry key will be backed up every time a backup job runs. If the backup folder does not contain the backup registry key file, the Plex registry key will not be imported on a restore. 39 | 40 | ### Script execution 41 | You must launch PlexBackup _as Administrator_ while being logged in under the same account your Plex Media Server runs. 42 | 43 | If you haven't done this already, you may need to adjust the PowerShell script execution policy to allow scripts to run. To check the current execution policy, run the following command from the PowerShell prompt: 44 | 45 | ```PowerShell 46 | Get-ExecutionPolicy 47 | ``` 48 | If the execution policy does not allow running scripts, do the following: 49 | 50 | - Start Windows PowerShell with the "Run as Administrator" option. 51 | - Run the following command: 52 | 53 | ```PowerShell 54 | Set-ExecutionPolicy RemoteSigned 55 | ``` 56 | 57 | This will allow running unsigned scripts that you write on your local computer and signed scripts downloaded from the Internet (okay, this is not a signed script, but if you copy it locally, make a non-destructive change--e.g. add a space character, remove the space character, and save the file--it should work). 58 | 59 | Alternatively, you may want to run the script as: 60 | 61 | ```PowerShell 62 | start powershell.exe -noprofile -executionpolicy bypass -file .\PlexBackup.ps1 -ConfigFile .\PlexBackup.ps1.json 63 | ``` 64 | 65 | For additional information, see [Running Scripts](https://docs.microsoft.com/en-us/previous-versions//bb613481(v=vs.85)) at Microsoft TechNet Library. 66 | 67 | ### Dependencies 68 | 69 | PlexBackup uses the following modules: 70 | 71 | - [ScriptVersion](https://www.powershellgallery.com/packages/ScriptVersion) 72 | - [ConfigFile](https://www.powershellgallery.com/packages/ConfigFile) 73 | - [StreamLogging](https://www.powershellgallery.com/packages/StreamLogging) 74 | - [SingleInstance](https://www.powershellgallery.com/packages/SingleInstance) 75 | 76 | To verify that the modules get installed, run the script manually. You may be [prompted](https://docs.microsoft.com/en-us/powershell/gallery/how-to/getting-support/bootstrapping-nuget) to update the [NuGet](https://www.nuget.org/downloads) version (or you can do it yourself in advance). 77 | 78 | ### Runtime parameters 79 | The default values of the PlexBackup script's runtime parameters are defined in code, but you can override some of them via command-line arguments or config file settings. 80 | 81 | ### Config file 82 | Config file is optional. The default config file must be named after the PlexBackup script with the `.json` extension, such as `PlexBackup.ps1.json`. If the file with this name does not exist in the backup script's folder, PlexBackup will not care. You can also specify a custom config file name (or more accurately, path) via the `ConfigFile` command-line argument ([see sample](PlexBackup.ps1.SAMPLE.json)). 83 | 84 | A config file must use [JSON formatting](https://www.json.org/), such as: 85 | 86 | ```JavaScript 87 | { 88 | "_meta": { 89 | "version": "1.0", 90 | "strict": false, 91 | "description": "Sample run-time settings for the PlexBackup.ps1 script." 92 | }, 93 | "Mode": { 94 | "_meta": { 95 | "set": "Backup,Continue,Restore", 96 | "default": "Backup" 97 | }, 98 | "value": null 99 | }, 100 | "Type": { 101 | "_meta": { 102 | "set": ",7zip,Robocopy", 103 | "default": "" 104 | }, 105 | "value": null 106 | }, 107 | "PlexAppDataDir": { 108 | "_meta": { 109 | "default": "$env:LOCALAPPDATA\\Plex Media Server" 110 | }, 111 | "value": null 112 | }, 113 | "BackupRootDir": { 114 | "_meta": { 115 | "default": "$PSScriptRoot" 116 | }, 117 | "value": null 118 | }, 119 | "BackupDir": { 120 | "_meta": { 121 | "default": null 122 | }, 123 | "value": null 124 | }, 125 | "TempDir": { 126 | "_meta": { 127 | "default": "$env:TEMP" 128 | }, 129 | "hasValue": true, 130 | "value": null 131 | }, 132 | "WakeUpDir": { 133 | "_meta": { 134 | "default": null 135 | }, 136 | "value": null 137 | }, 138 | "ArchiverPath": { 139 | "_meta": { 140 | "default": "$env:ProgramFiles\\7-Zip\\7z.exe" 141 | }, 142 | "value": null 143 | }, 144 | "Quiet": { 145 | "value": null 146 | }, 147 | "LogLevel": { 148 | "_meta": { 149 | "default": "None,Error,Warning,Info,Debug" 150 | }, 151 | "value": null 152 | }, 153 | "Log": { 154 | "value": true 155 | }, 156 | "LogFile": { 157 | "value": null 158 | }, 159 | "ErrorLog": { 160 | "value": null 161 | }, 162 | "ErrorLogFile": { 163 | "value": null 164 | }, 165 | "Keep": { 166 | "_meta": { 167 | "range": "0-[int]::MaxValue", 168 | "default": "3" 169 | }, 170 | "value": null 171 | }, 172 | "Retries": { 173 | "_meta": { 174 | "range": "0-[int]::MaxValue", 175 | "default": "5" 176 | }, 177 | "value": null 178 | }, 179 | "RetryWaitSec": { 180 | "_meta": { 181 | "range": "0-[int]::MaxValue", 182 | "default": "10" 183 | }, 184 | "value": null 185 | }, 186 | "RawOutput": { 187 | "value": null 188 | }, 189 | "Inactive": { 190 | "value": null 191 | }, 192 | "NoRestart": { 193 | "value": null 194 | }, 195 | "NoSingleton": { 196 | "value": null 197 | }, 198 | "NoVersion": { 199 | "value": null 200 | }, 201 | "NoLogo": { 202 | "value": null 203 | }, 204 | "Test": { 205 | "value": false 206 | }, 207 | "SendMail": { 208 | "_meta": { 209 | "set": "Never,Always,OnError,OnSuccess,OnBackup,OnBackupError,OnBackupSuccess,OnRestore,OnRestoreError,OnRestoreSuccess", 210 | "default": "Never" 211 | }, 212 | "value": null 213 | }, 214 | "SmtpServer": { 215 | "value": "smtp.gmail.com" 216 | }, 217 | "Port": { 218 | "_meta": { 219 | "range": "0-[int]::MaxValue", 220 | "default": "0" 221 | }, 222 | "value": 587 223 | }, 224 | "From": { 225 | "value": null 226 | }, 227 | "To": { 228 | "value": null 229 | }, 230 | "NoSsl": { 231 | "value": null 232 | }, 233 | "CredentialFile": { 234 | "value": null 235 | }, 236 | "NoCredential": { 237 | "value": null 238 | }, 239 | "Anonymous": { 240 | "value": null 241 | }, 242 | "SendLogFile": { 243 | "_meta": { 244 | "set": "Never,OnError,OnSuccess,Always", 245 | "default": "Never" 246 | }, 247 | "value": "OnError" 248 | }, 249 | "Logoff": { 250 | "value": null 251 | }, 252 | "Reboot": { 253 | "value": null 254 | }, 255 | "ForceReboot": { 256 | "value": null 257 | }, 258 | "ExcludeDirs": { 259 | "_meta": { 260 | "default": ["Diagnostics","Crash Reports","Updates","Logs"] 261 | }, 262 | "value": null 263 | }, 264 | "ExcludeFiles": { 265 | "_meta": { 266 | "default": ["*.bif"] 267 | }, 268 | "value": null 269 | }, 270 | "SpecialDirs": { 271 | "_meta": { 272 | "default": ["Plug-in Support\\Data\\com.plexapp.system\\DataItems\\Deactivated"] 273 | }, 274 | "value": null 275 | }, 276 | "PlexServiceName": { 277 | "_meta": { 278 | "default": "^Plex" 279 | }, 280 | "hasValue": false, 281 | "value": null 282 | }, 283 | "PlexServerFileName": { 284 | "_meta": { 285 | "default": "Plex Media Server.exe" 286 | }, 287 | "value": null 288 | }, 289 | "PlexServerPath": { 290 | "value": null 291 | }, 292 | "ArchiverOptionsCompress": { 293 | "_meta": { 294 | "comment": "The default options will always be applied. To include additional options, define them as an array.", 295 | "default": ["-r","-y"] 296 | }, 297 | "value": null 298 | }, 299 | "ArchiverOptionsExpand": { 300 | "_meta": { 301 | "comment": "The default options will always be applied. To include additional options, define them as an array.", 302 | "default": ["-aoa","-y"] 303 | }, 304 | "value": null 305 | }, 306 | "Machine": { 307 | "_meta": { 308 | "set": "x86,amd64", 309 | "default": null 310 | }, 311 | "value": null 312 | } 313 | } 314 | ``` 315 | The root `_meta` element describes the file and the file structure. It does not include any configuration settings. The important attributes of the `_meta` element are: 316 | 317 | - `version`: can be used to handle future file schema changes, and 318 | - `strictMode`: when set to `true` every config setting that needs to be used must have the `hasValue` attribute set to `true`; if the `strictMode` element is missing or if its value is set to `false`, every config setting that gets validated by the PowerShell's `if` statement will be used. 319 | 320 | The `_meta` elements under the configuration settings can contain anything (they are not processed at run time). In the [sample config file](PlexBackup.ps1.SAMPLE.json), they contain default values, value ranges and other helpful information, but they can be removed if not needed. 321 | 322 | Notice that to have the script recognize a `null` or empty value from the config file, you need to set the `hasValue` flags to `true`; otherwise, it will be ignored. 323 | 324 | Make sure you use proper JSON formatting (escape characters, etc) when defining the config values. In particular you need to escape backslash characters, so if your backup root folder is located on a NAS share, such as `\\MYNAS\Backups\Plex`, the configuration file setting must look like: 325 | 326 | ```JavaScript 327 | "BackupRootDir": { 328 | "_meta": { 329 | "default": "$PSScriptRoot" 330 | }, 331 | "value": "\\\\MYNAS\\Backups\\Plex" 332 | }, 333 | ``` 334 | 335 | 336 | ### Logging 337 | Use the `Log` switch to write operation progress and informational messages to a log file. By default, the log file will be created in the backup folder. The default log file name reflects the name of the script with the `.log` extension, such as `PlexBackup.ps1.log`. The default log file is created in the backup folder. You can specify a custom log file path via the `LogFile` argument. 338 | 339 | If you set the `ErrorLog` (or the `ErrorLogFile`) switch, the script will write error messages to a dedicated error log file (in addition to the standard log file, if one is defined). By default, the error log file will be created in the backup folder. The default error log file name reflects the name of the script with the `.err.log` extension, such as `PlexBackup.ps1.err.log`. 340 | 341 | You can control log output sung the `LogLevel` and `Quiet` switches. The default log level is `Info`, but it can be also set to `None`, `Error`, `Warning`, and `Debug`. When the `Quiet` switch is set, no log messages will be written to the console. 342 | 343 | You can control other log settings via the `PlexBackup.ps1.StreamLogging.json` configuration file (find out more at [StreamLogging page](https://github.com/alekdavis/StreamLogging)), but unless you know what you are doing, you should probably leave it alone. 344 | 345 | ### Debugging 346 | In case you need to troubleshoot issues with PlexBackup, run the script with the `Verbose` flag, which will display additional information about the script processing. You can also set the `Debug` flag that will make the script print the information about the function calls. Keep in mind that the verbose and debug messages are only printed to the console and will not be saved in the log file. 347 | 348 | ### Backup snapshots 349 | Every time you run a new backup job, the script will create a backup snapshot folder. The name of the folder will reflect the timestamp of when the script started. Use the `Keep` switch to specify how many backup snapshots you want to keep: `0` (keep all previously created backups), `1` (keep the current backup snapshot only), `2` (keep the current backup snapshot and one before it), `3` (keep the current backup snapshot and two most recent snapshots), and so on. The default value is `3`. 350 | 351 | ### Backup version 352 | When backing up data, PlexBackup records the version of Plex Media Server. If you try to restore a backup on a system with a different version of Plex Media Server, the operation will fail. To force the operation over a version mismatch, use the `NoVersion` parameter, but be aware that it may pose risks. Keep in mind that if you execute PlexBackup with the Plex Media Server process not running, version information will not be saved or checked during the backup or restore operations. 353 | 354 | ### Email notification 355 | Use the `SendMail` parameter to let PlexBackup know whether or when you want to receive email notifications about the backup job completion using one of the following values: 356 | 357 | - `Never`: email notification will not be sent (default) 358 | - `Always`: email notification will be sent always 359 | - `OnError`: receive a notification if an error occurs during any operation 360 | - `OnSuccess`: receive a notification only if an operation was successful 361 | - `OnBackup`: receive a notification about a new or resumed backup operation on error or success 362 | - `OnBackupError`: receive notification about a new or resumed backup operations on error only 363 | - `OnBackupSuccess`: receive a notifications about a new or resumed backup operations on success only 364 | - `OnRestore`: receive a notification about a restore operation on error or success 365 | - `OnRestoreError`: receive a notification about a failed restore operation only 366 | - `OnRestoreSuccess`: receive a notifications about a failed restore operation only 367 | 368 | To receive a copy of the log file along with the email notification, set the `SendLogFile` parameter to: 369 | 370 | - `Never`: the log file will not be sent as an email message attachment (default) 371 | - `Always`: the log file will be sent always 372 | - `OnError`: only send the log file if an error occurs 373 | - `OnSuccess`: only send the log file if no error occurs 374 | 375 | #### SMTP server 376 | When sending email notifications, Plex backup will need to know how to connect to the SMTP server. You can specify the server via the `SmtpServer` parameter, such as: `-SmtpServer smtp.gmail.com `. If the server is using a non-default SMTP port, use the `Port` parameter to specify the port, such as; `-Port 587`. If you do not want your message to be sent over encrypted (SSL) channel, set the `NoSsl` switch. 377 | 378 | #### SMTP credentials 379 | If your SMTP server does not require explicit authentication, use the `Anonymous` switch to tell PlexBackup to ignore explicit credentials; otherwise, the script will prompt you for credentials and save them in . If you want these credentials saved in a file (with password encrypted using the computer- and user-specific key) so you do not need to enter them every time the script runs, use the `SaveCredentials` switch. You can specify the path to the credential file via the `CredentialFile` parameters but if you don't, the script will try to use the default file named after the running script with the `.xml` extension, such as `PlexBackup.ps1.xml`. You can also generate the credential file in advance by running the following PowerShell command: 380 | 381 | ```PowerShell 382 | Get-Credential | Export-CliXml -Path "PathToFile.xml" 383 | ``` 384 | 385 | IMPORTANT: Most public providers, such as Gmail, Yahoo, Hotmail, and so on, have special requirements that you need to meet before you can use their SMTP servers to send email. For example, to use Gmail's SMTP server, you need to do the following: 386 | 387 | (a) If you have two-factor authentication or, as Google calls it _two-step verification_, enabled, you cannot use your own password, so you need to generate an application password and use it along with your Gmail email address (see [Sign in using App Passwords](https://support.google.com/mail/answer/185833?hl=en)). 388 | 389 | (b) If you are not using two-factor authentication, you can use your own password, but may need to enable less secure application access in your account settings (see [Let less secure apps access your account](https://support.google.com/accounts/answer/6010255?hl=en)). 390 | 391 | For additional information or if you run into any issues, check support articles covering your provider. 392 | 393 | #### Addresses 394 | By default, PlexBackup will use the username provided via the SMTP credentials as both the `To` and `From` addresses, but you can set them explicitly via the `To` and `From` parameters. If the `To` parameter is not specified, the recipient's address will be the same as the sender's. 395 | 396 | ## See also 397 | 398 | - [Getting started](GETTING%20STARTED.md) 399 | - [Scheduled Plex backup](SCHEDULED%20PLEX%20BACKUP.md) 400 | - [Frequently asked questions (FAQs)](FAQs.md) 401 | 402 | ## Syntax 403 | ```PowerShell 404 | .\PlexBackup.ps1 ` 405 | [[-Mode ] | -Backup | -Continue | -Update | -Restore] ` 406 | [[-Type ] | -SevenZip | -Robocopy ] ` 407 | [-ModuleDir ] ` 408 | [-ConfigFile ] ` 409 | [-PlexAppDataDir ] ` 410 | [-BackupRootDir ] ` 411 | [-BackupDir ] ` 412 | [-TempDir ] ` 413 | [-WakeUpDir ] ` 414 | [-ArchiverPath ] ` 415 | [-Quiet | -Q] ` 416 | [-LogLevel ] ` 417 | [-Log | -L] ` 418 | [-LogFile ] ` 419 | [-ErrorLog] ` 420 | [-ErrorLogFile ] ` 421 | [-Keep ] ` 422 | [-Retries ] ` 423 | [-RetryWaitSec ] ` 424 | [-RawOutput]` 425 | [-Inactive] ` 426 | [-NoRestart] ` 427 | [-NoSingleton] ` 428 | [-NoVersion] ` 429 | [-NoLogo] ` 430 | [-Test] ` 431 | [-SendMail ] ` 432 | [-SmtpServer ] ` 433 | [-Port ] ` 434 | [-From ] ` 435 | [-To ] ` 436 | [-NoSsl] ` 437 | [-CredentialFile ] ` 438 | [-SaveCredential] ` 439 | [-Anonymous] ` 440 | [-SendLogFile ] ` 441 | [-ModulePath ] ` 442 | [-Logoff] ` 443 | [-Reboot] ` 444 | [-ForceReboot] ` 445 | [-Machine ] ` 446 | [] 447 | 448 | ``` 449 | ### Arguments 450 | 451 | `Mode` 452 | 453 | Specifies the mode of operation: 454 | 455 | - `Backup` (default) 456 | - `Continue` 457 | - `Restore` 458 | 459 | `Backup` 460 | 461 | Shortcut for `-Mode Backup`. 462 | 463 | `Continue` 464 | 465 | Shortcut for `-Mode Continue`. 466 | 467 | `Restore` 468 | 469 | Shortcut for `-Mode Restore`. 470 | 471 | `Type` 472 | 473 | Specifies the non-default type of backup method: 474 | 475 | - `7zip` 476 | - `Robocopy` 477 | 478 | By default, the script will use the built-in compression. 479 | 480 | `SevenZip` 481 | 482 | Shortcut for `-Type 7zip`. 483 | 484 | `Robocopy` 485 | 486 | Shortcut for `-Type Robocopy`. 487 | 488 | `ModuleDir` 489 | 490 | Optional path to directory holding the modules used by this script. This can be useful if the script runs on the system with no or restricted access to the Internet. By default, the module path will point to the `Modules` folder in the script's folder. 491 | 492 | `ConfigFile` 493 | 494 | Path to the optional custom config file. The default config file is named after the script with the `.json` extension, such as 'PlexBackup.ps1.json'. 495 | 496 | `PlexAppDataDir` 497 | 498 | Location of the Plex Media Server application data folder. 499 | 500 | `BackupRootDir` 501 | 502 | Path to the root backup folder holding timestamped backup subfolders. If not specified, the script folder will be used. 503 | 504 | `BackupDirPath` 505 | 506 | When running the script in the `Restore` mode, holds path to the backup folder (by default, the subfolder with the most recent timestamp in the name located in the backup root folder will be used). 507 | 508 | `TempDir` 509 | 510 | Temp folder used to stage the archiving job (use local drive for efficiency). To bypass the staging step, set this parameter to null or empty string. 511 | 512 | `WakeUpDir` 513 | 514 | Optional path to a remote share that may need to be woken up before starting Plex Media Server. 515 | 516 | `ArchiverPath` 517 | 518 | Defines the path to the 7-zip command line tool (7z.exe) which is required when running the script with the `-Type 7zip` or `-SevenZip` switch. Default: `$env:ProgramFiles\7-Zip\7z.exe`. 519 | 520 | `Quiet` 521 | 522 | Set this switch to suppress log entries sent to a console. 523 | 524 | `LogLevel` 525 | 526 | Specifies the log level of the output: 527 | 528 | - `None` 529 | - `Error` 530 | - `Warning` 531 | - `Info` 532 | - `Debug` 533 | 534 | `Log` 535 | 536 | When set to true, informational messages will be written to a log file. The default log file will be created in the backup folder and will be named after this script with the `.log` extension, such as `PlexBackup.ps1.log`. 537 | 538 | `LogFile` 539 | 540 | Use this switch to specify a custom log file location. When this parameter is set to a non-null and non-empty value, the `-Log` switch can be omitted. 541 | 542 | `ErrorLog` 543 | 544 | When set to true, error messages will be written to an error log file. The default error log file will be created in the backup folder and will be named after this script with the `.err.log` extension, such as `PlexBackup.ps1.err.log`. 545 | 546 | `ErrorLogFile` 547 | 548 | Use this switch to specify a custom error log file location. When this parameter is set to a non-null and non-empty value, the `-ErrorLog` switch can be omitted. 549 | 550 | `Keep` 551 | 552 | Number of old backups to keep: 553 | `0` - retain all previously created backups, 554 | `1` - latest backup only, 555 | `2` - latest and one before it, 556 | `3` - latest and two before it, 557 | and so on. 558 | 559 | `Retries` 560 | 561 | The number of retries on failed copy operations (corresponds to the Robocopy `/R` switch). 562 | 563 | `RetryWaitSec` 564 | 565 | Specifies the wait time between retries in seconds (corresponds to the Robocopy `/W` switch). 566 | 567 | `RawOutput` 568 | 569 | Set this switch to display raw output from the external commands, such as Robocopy or 7-zip. 570 | 571 | `Inactive` 572 | 573 | When set, allows the script to continue if Plex Media Server is not running. 574 | 575 | `NoRestart` 576 | 577 | Set this switch to not start the Plex Media Server process at the end of the operation (could be handy for restores, so you can double check that all is good before launching Plex media Server). 578 | 579 | `NoSingleton` 580 | 581 | Set this switch to ignore check for multiple script instances running concurrently. 582 | 583 | `NoVersion` 584 | 585 | Forces restore to ignore version mismatch between the current version of Plex Media Server and the version of Plex Media Server active during backup. 586 | 587 | `NoLogo` 588 | 589 | Specify this command-line switch to not print version and copyright info. 590 | 591 | `Test` 592 | 593 | When turned on, the script will not generate backup files or restore Plex app data from the backup files. 594 | 595 | `SendMail` 596 | 597 | Indicates in which case the script must send an email notification about the result of the operation: 598 | 599 | - `Never` (default) 600 | - `Always` 601 | - `OnError` (for any operation) 602 | - `OnSuccess` (for any operation) 603 | - `OnBackup` (for both the Backup and Continue modes on either error or success) 604 | - `OnBackupError` 605 | - `OnBackupSuccess` 606 | - `OnRestore` (on either error or success) 607 | - `OnRestoreError` 608 | - `OnRestoreSuccess` 609 | 610 | `SmtpServer` 611 | 612 | Defines the SMTP server host. If not specified, the notification will not be sent. 613 | 614 | `Port` 615 | 616 | Specifies an alternative port on the SMTP server. Default: 0 (zero, i.e. default port 25 will be used). 617 | 618 | `From` 619 | 620 | Specifies the email address when email notification sender. If this value is not provided, the username from the credentails saved in the credentials file or entered at the credentials prompt will be used. If the From address cannot be determined, the notification will not be sent. 621 | 622 | `To` 623 | 624 | Specifies the email address of the email recipient. If this value is not provided, the addressed defined in the To parameter will be used. 625 | 626 | `NoSsl` 627 | 628 | Tells the script not to use the Secure Sockets Layer (SSL) protocol when connecting to the SMTP server. By default, SSL is used. 629 | 630 | `CredentialFile` 631 | 632 | Path to the file holding username and encrypted password of the account that has permission to send mail via the SMTP server. You can generate the file via the following PowerShell command: 633 | 634 | ```PowerShell 635 | Get-Credential | Export-CliXml -Path "PathToFile.xml" 636 | ``` 637 | 638 | The default log file will be created in the backup folder and will be named after this script with the '.xml' extension, such as 'PlexBackup.ps1.xml'. 639 | 640 | `SaveCredential` 641 | 642 | When set, the SMTP credentials will be saved in a file (encrypted with user- and machine-specific key) for future use. 643 | 644 | `Anonymous` 645 | 646 | Tells the script to not use credentials when sending email notifications. 647 | 648 | `SendLogFile` 649 | 650 | Indicates in which case the script must send an attachment along with th email notification: 651 | 652 | - `Never` (default) 653 | - `Always` 654 | - `OnError` 655 | - `OnSuccess` 656 | 657 | `Logoff` 658 | 659 | Specify this command-line switch to log off all user accounts (except the running one) before starting Plex Media Server. This may help address issues with remote drive mappings under the wrong credentials. 660 | 661 | `Reboot` 662 | 663 | Reboots the computer after a successful backup operation (ignored on restore). 664 | 665 | `ForceReboot` 666 | 667 | Forces an immediate restart of the computer after a successfull backup operation (ignored on restore). 668 | 669 | `Machine` 670 | 671 | Provided for rare cases when the backup script fails to re-launch Plex Media Server due to error `87: The parameter is incorrect`. If you run into this problem (which is most likely due to a bad OS patch), set the machine flag to one of the following: 672 | 673 | - `x86` 674 | - `amd64` 675 | 676 | Keep in mind that the machine flag should match the architecture of the Plex Media Server executable (not the operating system), so for a 32-bit process, use the `x86` flag, and for the 64-bit use the `amd64` flag. If you are not sure which pone to use, try one and if it does not work, try the other. 677 | 678 | 679 | `` 680 | 681 | Common PowerShell parameters (the script is not using these explicitly). 682 | 683 | ## Returns 684 | 685 | To check whether the backup script executed successfully or encountered an error, check the value of the `$LASTEXITCODE` variable: 686 | 687 | - `0` (zero) indicates success 688 | - `1` indicates error 689 | 690 | ## Examples 691 | 692 | The following examples assume that the the default settings are used for the unspecified script arguments. 693 | 694 | ### Example 1 695 | ```PowerShell 696 | .\PlexBackup.ps1 697 | ``` 698 | Backs up Plex application data to the default backup location using the default Windows compression. 699 | 700 | ### Example 2 701 | ```PowerShell 702 | .\PlexBackup.ps1 -ConfigFile "C:\Scripts\PlexBackup.ps1.ROBOCOPY.json 703 | ``` 704 | Runs the script with the non-default settings specified in the custom config file. 705 | 706 | ### Example 3 707 | ```PowerShell 708 | .\PlexBackup.ps1 -Log -ErrorLog -Keep 5 709 | ``` 710 | Backs up Plex application data to the default backup location using file and folder compression. Writes progress to the default log file. Writes error messages to the default error log file. Keeps current and four previous backup snapshots (total of five). 711 | 712 | ### Example 4 713 | ```PowerShell 714 | .\PlexBackup.ps1 -Type Robocopy 715 | .\PlexBackup.ps1 -Robocopy 716 | ``` 717 | Creates a mirror copy of the Plex application data (minus the non-essential folders) in the default backup location using the Robocopy command. 718 | 719 | ### Example 5 720 | ```PowerShell 721 | .\PlexBackup.ps1 -Type 7zip 722 | .\PlexBackup.ps1 -SevenZip 723 | ``` 724 | Backs up Plex application data to the default backup location using the 7-zip command-line tool. 725 | 726 | ### Example 6 727 | ```PowerShell 728 | .\PlexBackup.ps1 -BackupRootDir "\\MYNAS\Backup\Plex" 729 | ``` 730 | Backs up Plex application data to the specified backup location on a network share using file and folder compression. 731 | 732 | ### Example 7 733 | ```PowerShell 734 | .\PlexBackup.ps1 -Mode Continue 735 | .\PlexBackup.ps1 -Continue 736 | ``` 737 | Continues the last backup process (using file and folder compression) where it left off. 738 | 739 | ### Example 8 740 | ```PowerShell 741 | .\PlexBackup.ps1 -Continue -Robocopy 742 | ``` 743 | Reruns the last backup process using a mirror copy. 744 | 745 | ### Example 9 746 | ```PowerShell 747 | .\PlexBackup.ps1 -Mode Restore 748 | .\PlexBackup.ps1 -Restore 749 | ``` 750 | Restores Plex application data from the latest backup from the default folder holding compressed data. 751 | 752 | ### Example 10 753 | ```PowerShell 754 | .\PlexBackup.ps1 -Restore -Robocopy 755 | ``` 756 | Restores Plex application data from the latest backup in the default folder holding a mirror copy of the Plex application data folder. 757 | 758 | ### Example 11 759 | ```PowerShell 760 | .\PlexBackup.ps1 -Mode Restore -BackupDirPath "\\MYNAS\PlexBackup\20190101183015" 761 | ``` 762 | Restores Plex application data from the specified backup folder holding compressed data on a remote share. 763 | 764 | ### Example 12 765 | ```PowerShell 766 | .\PlexBackup.ps1 -SendMail Always -SaveCredential -SendLogFile OnError -SmtpServer smtp.gmail.com -Port 587 767 | ``` 768 | Runs a backup job and sends an email notification over an SSL channel. If the backup operation fails, the log file will be attached to the email message. The sender's and the recipient's email addresses will determined from the username of the credential object. The credential object will be set either from the credential file or, if the file does not exist, via a user prompt (in the latter case, the credential object will be saved in the credential file with password encrypted using a user- and computer-specific key). 769 | 770 | ### Example 13 771 | ```PowerShell 772 | Get-Help .\PlexBackup.ps1 773 | ``` 774 | Shows help information. 775 | -------------------------------------------------------------------------------- /SCHEDULED PLEX BACKUP.md: -------------------------------------------------------------------------------- 1 | # Scheduled Plex backup 2 | 3 | You can use [Windows Task Scheduler](https://docs.microsoft.com/en-us/windows/desktop/taskschd/task-scheduler-start-page) to run the Plex backup script ([PlexBackup.ps1](PlexBackup.ps1)) on a schedule. 4 | 5 | ## Launch Windows Task Scheduler 6 | 7 | Enter _Task Scheduler_ in the __Windows Search__ box and launch Task Scheduler. 8 | 9 | ## Create a new task 10 | 11 | In Task Scheduler's __Actions__ panel, click __Create Task...__. You can do this in a number of ways, but in the end, your task should look similar to this one: 12 | 13 | ![Task Scheduler](https://user-images.githubusercontent.com/2113681/52493659-e50aa880-2b80-11e9-84b2-f3a2fdbd7112.PNG) 14 | 15 | You can set the task properties in the new task creation wizard or modify after creating the task (double-click the task to open its properties for editing). 16 | 17 | When setting up the Plex backup scheduled task, please make sure that it runs: 18 | 19 | - only when use is logged on 20 | - as the same account as you Plex Media Server process 21 | - with highest privileges 22 | 23 | The task __action__ (in the __Actions__ properties) must be: 24 | 25 | `PowerShell.exe` 26 | 27 | The program/script __arguments__ of the task action would be similar to: 28 | 29 | `-WindowStyle Hidden -ExecutionPolicy Bypass "C:\PathToYourBackupScript\PlexBackup.ps1"` 30 | 31 | The following screenshots are just for your reference. You may want to adjust them to fit your needs. 32 | 33 | ## General 34 | 35 | ![General](https://user-images.githubusercontent.com/2113681/52495321-2dc46080-2b85-11e9-8f27-194950df07da.PNG) 36 | 37 | ## Triggers 38 | 39 | ![Triggers](https://user-images.githubusercontent.com/2113681/52493677-efc53d80-2b80-11e9-9918-98643313a70e.PNG) 40 | 41 | ### Trigger properties 42 | 43 | ![Trigger properties](https://user-images.githubusercontent.com/2113681/52493684-f3f15b00-2b80-11e9-8646-5f108ce4b954.PNG) 44 | 45 | ## Actions 46 | 47 | ![Actions](https://user-images.githubusercontent.com/2113681/52493692-f9e73c00-2b80-11e9-91f8-d3f438cb5c20.PNG) 48 | 49 | ### Action properties 50 | 51 | ![Action properties](https://user-images.githubusercontent.com/2113681/52499026-53566780-2b8f-11e9-8990-38eee6525340.PNG) 52 | 53 | The program/script __arguments__ of the task action would be something like: 54 | 55 | `-WindowStyle Hidden -ExecutionPolicy Bypass "C:\PathToYourBackupScript\PlexBackup.ps1"` 56 | 57 | ## Conditions 58 | 59 | ![Conditions](https://user-images.githubusercontent.com/2113681/52493716-023f7700-2b81-11e9-96a5-b673da4379f6.PNG) 60 | 61 | ## Settings 62 | 63 | ![Settings](https://user-images.githubusercontent.com/2113681/52493729-066b9480-2b81-11e9-8130-6965da7de755.PNG) 64 | 65 | ## Tip 66 | 67 | When setting up the Plex backup schedule, make sure the backup job does not run at the same time as Plex Media Server's (PMS') scheduled tasks (check Settings - Scheduled Tasks under your PMS instance). 68 | 69 | ## Issues 70 | 71 | There is an issue with the Windows Task Scheduler that for some reason may prevent Plex Media Server (PMS) from connecting to the remote shares (such as NAS shares) hosting media files or force connection under a wrong security context. The problem does not occur when running the backup interactively, only when it runs as a scheduled task. I am still not sure what the root cause is, but to address the problem, try using the following command-line options: 72 | 73 | - `Logoff`: logs off all other logged in users (I noticed that it normally happens when other users with different privileges are logged in) 74 | - `Reboot`: reboot the computer after successful operation (if the `Logoff` option does not help, try rebooting the computer; notice that this may prompt user to accept the restart and keep waiting until the user responds) 75 | - `ForceReboot`: reboot the computer without the prompt (if all else fails try this option) 76 | 77 | Keep in mind that unless you run PMS as a service, it would require the user to log on (otherwise, PMS will not start). You can set up your system to auto log on as the PMS user account, but if you do, make sure that you lock Windows after logging on. Here is an article explaining how this can be done: 78 | 79 | [Automatically log in to Windows and then lock straight away](https://softwarerecs.stackexchange.com/questions/9825/automatically-log-in-to-windows-and-then-lock-straight-away) 80 | --------------------------------------------------------------------------------