├── .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 |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 | | 2375 |
2378 | #VALUE_RESULT# 2379 | | 2380 |
2383 | #VALUE_ERROR_INFO# 2384 | | 2385 |
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 | | 2397 |
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 | | 2408 |