Public/Sync-PatMedia.ps1
|
function Sync-PatMedia { <# .SYNOPSIS Syncs media from a Plex playlist to a destination folder. .DESCRIPTION Downloads media files from a Plex playlist to a destination folder with Plex-compatible folder structure. Optionally removes files at the destination that are not in the playlist. Supports subtitle downloads and progress reporting. .PARAMETER PlaylistName The name of the playlist to sync. Defaults to 'Travel'. Supports tab completion. .PARAMETER PlaylistId The unique identifier of the playlist to sync. Use this instead of PlaylistName when you need to specify a playlist by its numeric ID. .PARAMETER Destination The destination path where media files will be synced (e.g., 'E:\' for a USB drive). .PARAMETER SkipSubtitles When specified, does not download external subtitle files. By default, subtitles are included in the sync. .PARAMETER SkipRemoval When specified, does not remove files at the destination that are not in the playlist. .PARAMETER Force Skip the space sufficiency check and proceed even if there may not be enough space. .PARAMETER PassThru Returns the sync plan after completion. .PARAMETER ServerUri The base URI of the Plex server. If not specified, uses the default stored server. .PARAMETER Token The Plex authentication token. Required when using -ServerUri to authenticate with the server. If not specified with -ServerUri, requests may fail with 401. .PARAMETER SyncWatchStatus After syncing media, compares watch status between the source and target servers and syncs watched items from the target (travel) server back to the source (home) server. Requires -SourceServerName and -TargetServerName parameters. .PARAMETER RemoveWatched After syncing, prompts to remove watched items from the playlist. Items are first marked as watched on the source server (if -SyncWatchStatus is also specified), then removed from the playlist. Requires -SourceServerName and -TargetServerName. .PARAMETER SourceServerName The name of the source (home) server for watch status operations. Required when using -SyncWatchStatus or -RemoveWatched. .PARAMETER TargetServerName The name of the target (travel/portable) server for watch status operations. Required when using -SyncWatchStatus or -RemoveWatched. .EXAMPLE Sync-PatMedia -Destination 'E:\' Syncs the default 'Travel' playlist to drive E:, including subtitles. .EXAMPLE Sync-PatMedia -Destination 'E:\' -SkipSubtitles Syncs the 'Travel' playlist without downloading external subtitles. .EXAMPLE Sync-PatMedia -PlaylistName 'Vacation' -Destination 'E:\' -WhatIf Shows what would be synced from the 'Vacation' playlist without making changes. .EXAMPLE Sync-PatMedia -Destination 'E:\' -SourceServerName 'Home' -TargetServerName 'Travel' -SyncWatchStatus After vacation: syncs media and marks items watched on the travel server as watched on home. .EXAMPLE Sync-PatMedia -Destination 'E:\' -SourceServerName 'Home' -TargetServerName 'Travel' -SyncWatchStatus -RemoveWatched Full vacation workflow: syncs media, syncs watch status, then removes watched items from playlist. .OUTPUTS PlexAutomationToolkit.SyncPlan (with -PassThru) Returns the sync plan with operation results. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', 'commandName', Justification = 'Standard ArgumentCompleter parameter, not always used' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', 'parameterName', Justification = 'Standard ArgumentCompleter parameter, not always used' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', 'commandAst', Justification = 'Standard ArgumentCompleter parameter, not always used' )] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] param ( [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [ValidateNotNullOrEmpty()] [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $quoteChar = '' $strippedWord = $wordToComplete if ($wordToComplete -match "^([`"'])(.*)$") { $quoteChar = $Matches[1] $strippedWord = $Matches[2] } $getParams = @{ ErrorAction = 'SilentlyContinue' } if ($fakeBoundParameters.ContainsKey('ServerUri')) { $getParams['ServerUri'] = $fakeBoundParameters['ServerUri'] } $playlists = Get-PatPlaylist @getParams foreach ($playlist in $playlists) { if ($playlist.Title -ilike "$strippedWord*") { $title = $playlist.Title if ($quoteChar) { $text = "$quoteChar$title$quoteChar" } elseif ($title -match '\s') { $text = "'$title'" } else { $text = $title } [System.Management.Automation.CompletionResult]::new( $text, $title, 'ParameterValue', $title ) } } })] [string] $PlaylistName = 'Travel', [Parameter(Mandatory = $true, ParameterSetName = 'ById')] [ValidateRange(1, [int]::MaxValue)] [int] $PlaylistId, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Destination, [Parameter(Mandatory = $false)] [switch] $SkipSubtitles, [Parameter(Mandatory = $false)] [switch] $SkipRemoval, [Parameter(Mandatory = $false)] [switch] $Force, [Parameter(Mandatory = $false)] [switch] $PassThru, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-PatServerUri -Uri $_ })] [string] $ServerUri, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $Token, [Parameter(Mandatory = $false)] [switch] $SyncWatchStatus, [Parameter(Mandatory = $false)] [switch] $RemoveWatched, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $SourceServerName, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $TargetServerName ) begin { # Use default server if ServerUri not specified $server = $null $effectiveUri = $ServerUri if (-not $ServerUri) { try { $server = Get-PatStoredServer -Default -ErrorAction 'Stop' if (-not $server) { throw "No default server configured. Use Add-PatServer with -Default or specify -ServerUri." } $effectiveUri = $server.uri Write-Verbose "Using default server: $effectiveUri" } catch { throw "Failed to get default server: $($_.Exception.Message)" } } } process { try { # Get the sync plan $syncPlanParams = @{ Destination = $Destination ErrorAction = 'Stop' } if ($ServerUri) { $syncPlanParams['ServerUri'] = $ServerUri } if ($Token) { $syncPlanParams['Token'] = $Token } if ($PlaylistId) { $syncPlanParams['PlaylistId'] = $PlaylistId } else { $syncPlanParams['PlaylistName'] = $PlaylistName } Write-Verbose "Generating sync plan..." $syncPlan = Get-PatSyncPlan @syncPlanParams # Display summary $downloadSizeGB = [math]::Round($syncPlan.BytesToDownload / 1GB, 2) $removeSizeGB = [math]::Round($syncPlan.BytesToRemove / 1GB, 2) Write-Verbose "Sync plan for playlist '$($syncPlan.PlaylistName)':" Write-Verbose " Items to add: $($syncPlan.ItemsToAdd) ($downloadSizeGB GB)" Write-Verbose " Items to remove: $($syncPlan.ItemsToRemove) ($removeSizeGB GB)" Write-Verbose " Items unchanged: $($syncPlan.ItemsUnchanged)" # Check space if (-not $syncPlan.SpaceSufficient -and -not $Force) { $freeGB = [math]::Round($syncPlan.DestinationFree / 1GB, 2) throw "Insufficient space at destination. Free: $freeGB GB, Required: $downloadSizeGB GB. Use -Force to proceed anyway." } # Confirm with user $syncDescription = "Sync $($syncPlan.ItemsToAdd) items ($downloadSizeGB GB)" if ($syncPlan.ItemsToRemove -gt 0 -and -not $SkipRemoval) { $syncDescription += ", remove $($syncPlan.ItemsToRemove) items ($removeSizeGB GB)" } if (-not $PSCmdlet.ShouldProcess($Destination, $syncDescription)) { return } # Remove files first (to free up space) if (-not $SkipRemoval -and $syncPlan.RemoveOperations.Count -gt 0) { Write-Verbose "Removing $($syncPlan.RemoveOperations.Count) items..." $removeCount = 0 foreach ($removeOp in $syncPlan.RemoveOperations) { $removeCount++ $percentComplete = [int](($removeCount / $syncPlan.RemoveOperations.Count) * 100) Write-Progress -Activity "Removing old files" ` -Status "Removing $removeCount of $($syncPlan.RemoveOperations.Count)" ` -PercentComplete $percentComplete ` -CurrentOperation $removeOp.Path ` -Id 1 Write-Verbose "Removing: $($removeOp.Path)" Remove-Item -Path $removeOp.Path -Force -ErrorAction SilentlyContinue # Try to remove empty parent directories $parent = Split-Path -Path $removeOp.Path -Parent while ($parent -and (Test-Path -Path $parent)) { $items = Get-ChildItem -Path $parent -Force -ErrorAction SilentlyContinue if (-not $items) { Remove-Item -Path $parent -Force -ErrorAction SilentlyContinue $parent = Split-Path -Path $parent -Parent } else { break } } } Write-Progress -Activity "Removing old files" -Completed -Id 1 } # Download files if ($syncPlan.AddOperations.Count -gt 0) { Write-Verbose "Downloading $($syncPlan.AddOperations.Count) items..." # Retrieve token once before the download loop (supports vault storage) # Use explicitly provided Token parameter first, then fall back to server config $effectiveToken = if ($Token) { $Token } elseif ($server) { Get-PatServerToken -ServerConfig $server } else { $null } $downloadCount = 0 $downloadedBytes = 0 $totalBytes = $syncPlan.BytesToDownload foreach ($addOp in $syncPlan.AddOperations) { $downloadCount++ $overallPercent = [int](($downloadedBytes / $totalBytes) * 100) $itemDisplay = if ($addOp.Type -eq 'episode') { "$($addOp.GrandparentTitle) - S$($addOp.ParentIndex.ToString('D2'))E$($addOp.Index.ToString('D2'))" } else { "$($addOp.Title) ($($addOp.Year))" } Write-Progress -Activity "Syncing media" ` -Status "Downloading $downloadCount of $($syncPlan.AddOperations.Count): $itemDisplay" ` -PercentComplete $overallPercent ` -Id 1 # Construct download URL (token passed via header, not URL for security) $downloadUrl = "$effectiveUri$($addOp.PartKey)?download=1" Write-Verbose "Downloading: $($addOp.DestinationPath)" try { $downloadParams = @{ Uri = $downloadUrl OutFile = $addOp.DestinationPath ExpectedSize = $addOp.MediaSize Resume = $true ErrorAction = 'Stop' } if ($effectiveToken) { $downloadParams['Token'] = $effectiveToken } Invoke-PatFileDownload @downloadParams | Out-Null # Download subtitles if requested if (-not $SkipSubtitles -and $addOp.SubtitleCount -gt 0) { # Get full media info to get subtitle streams $mediaInfoParams = @{ RatingKey = $addOp.RatingKey ErrorAction = 'Stop' } if ($ServerUri) { $mediaInfoParams['ServerUri'] = $ServerUri } if ($Token) { $mediaInfoParams['Token'] = $Token } $mediaInfo = Get-PatMediaInfo @mediaInfoParams if ($mediaInfo.Media -and $mediaInfo.Media[0].Part) { $subtitleStreams = $mediaInfo.Media[0].Part[0].Streams | Where-Object { $_.StreamType -eq 3 -and $_.External -and $_.Key } foreach ($sub in $subtitleStreams) { $lang = if ($sub.LanguageCode) { $sub.LanguageCode } else { 'und' } $format = if ($sub.Format) { $sub.Format } else { 'srt' } $basePath = [System.IO.Path]::ChangeExtension($addOp.DestinationPath, $null).TrimEnd('.') $subPath = "$basePath.$lang.$format" # Token passed via header, not URL for security $subUrl = "$effectiveUri$($sub.Key)?download=1" Write-Verbose "Downloading subtitle: $subPath" try { $subDownloadParams = @{ Uri = $subUrl OutFile = $subPath ErrorAction = 'Stop' } if ($effectiveToken) { $subDownloadParams['Token'] = $effectiveToken } Invoke-PatFileDownload @subDownloadParams | Out-Null } catch { Write-Warning "Failed to download subtitle for '$itemDisplay': $($_.Exception.Message)" } } } } $downloadedBytes += $addOp.MediaSize } catch { Write-Warning "Failed to download '$itemDisplay': $($_.Exception.Message)" } } Write-Progress -Activity "Syncing media" -Completed -Id 1 } Write-Verbose "Sync completed" # Handle vacation workflow: sync watch status and remove watched items if ($SyncWatchStatus -or $RemoveWatched) { # Validate server names are provided if (-not $SourceServerName -or -not $TargetServerName) { Write-Warning "SyncWatchStatus and RemoveWatched require -SourceServerName and -TargetServerName parameters. Skipping watch status operations." } else { Write-Verbose "Checking for watch status differences..." # Get items watched on target (travel server) but not source (home server) $watchDiffs = @(Compare-PatWatchStatus -SourceServerName $TargetServerName ` -TargetServerName $SourceServerName ` -WatchedOnSourceOnly ` -ErrorAction SilentlyContinue) if ($watchDiffs.Count -gt 0) { Write-Verbose "Found $($watchDiffs.Count) items watched on '$TargetServerName'" # Sync watch status first if requested if ($SyncWatchStatus) { if ($PSCmdlet.ShouldProcess("$($watchDiffs.Count) items", "Sync watch status from '$TargetServerName' to '$SourceServerName'")) { $syncResults = Sync-PatWatchStatus -SourceServerName $TargetServerName ` -TargetServerName $SourceServerName ` -PassThru ` -Confirm:$false $successCount = @($syncResults | Where-Object { $_.Status -eq 'Success' }).Count Write-Verbose "Synced watch status for $successCount items" } } # Remove watched items from playlist if requested if ($RemoveWatched) { # Get playlist with items to map RatingKey to PlaylistItemId $getPlaylistParams = @{ IncludeItems = $true ErrorAction = 'Stop' } if ($PlaylistId) { $getPlaylistParams['PlaylistId'] = $PlaylistId } else { $getPlaylistParams['PlaylistName'] = $PlaylistName } if ($ServerUri) { $getPlaylistParams['ServerUri'] = $ServerUri } if ($Token) { $getPlaylistParams['Token'] = $Token } $playlist = Get-PatPlaylist @getPlaylistParams # Build lookup from RatingKey to PlaylistItem $ratingKeyToItem = @{} foreach ($item in $playlist.Items) { $ratingKeyToItem[[string]$item.RatingKey] = $item } # Find watched items that are in the playlist # Use TargetRatingKey which corresponds to the source (home) server's keys $itemsToRemove = @() foreach ($diff in $watchDiffs) { $key = [string]$diff.TargetRatingKey if ($ratingKeyToItem.ContainsKey($key)) { $itemsToRemove += @{ PlaylistItem = $ratingKeyToItem[$key] WatchDiff = $diff } } } if ($itemsToRemove.Count -gt 0) { $removeDescription = "$($itemsToRemove.Count) watched items from playlist '$($playlist.Title)'" if ($PSCmdlet.ShouldProcess($removeDescription, 'Remove')) { $removedCount = 0 foreach ($itemToRemove in $itemsToRemove) { $playlistItem = $itemToRemove.PlaylistItem $itemDisplay = if ($playlistItem.Type -eq 'episode') { "$($playlistItem.GrandparentTitle) - S$($playlistItem.ParentIndex.ToString('D2'))E$($playlistItem.Index.ToString('D2'))" } else { "$($playlistItem.Title) ($($playlistItem.Year))" } try { Remove-PatPlaylistItem -PlaylistId $playlist.PlaylistId ` -PlaylistItemId $playlistItem.PlaylistItemId ` -Confirm:$false ` -ErrorAction Stop $removedCount++ Write-Verbose "Removed '$itemDisplay' from playlist" } catch { Write-Warning "Failed to remove '$itemDisplay': $($_.Exception.Message)" } } Write-Verbose "Removed $removedCount watched items from playlist" } } else { Write-Verbose "No watched items found in playlist to remove" } } } else { Write-Verbose "No watched items found on '$TargetServerName' to process" } } } if ($PassThru) { $syncPlan } } catch { throw "Failed to sync media: $($_.Exception.Message)" } } } |