Public/Get-PatSyncPlan.ps1
|
function Get-PatSyncPlan { <# .SYNOPSIS Generates a sync plan for transferring media from a Plex playlist to a destination. .DESCRIPTION Analyzes a Plex playlist and compares it against the destination folder to determine what files need to be added or removed. Calculates space requirements and verifies available disk space. .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 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. .EXAMPLE Get-PatSyncPlan -Destination 'E:\' Shows what files would be synced from the default 'Travel' playlist to drive E:. .EXAMPLE Get-PatSyncPlan -PlaylistName 'Vacation' -Destination 'D:\PlexMedia' Shows the sync plan for the 'Vacation' playlist. .OUTPUTS PlexAutomationToolkit.SyncPlan Object with properties: - PlaylistName: Name of the playlist - PlaylistId: ID of the playlist - Destination: Target path - TotalItems: Total items in playlist - ItemsToAdd: Number of items to download - ItemsToRemove: Number of items to delete - ItemsUnchanged: Number of items already synced - BytesToDownload: Total bytes to download - BytesToRemove: Total bytes to free by removal - DestinationFree: Current free space at destination - DestinationAfter: Projected free space after sync - SpaceSufficient: Whether there's enough space - AddOperations: Array of items to add - RemoveOperations: Array of items to remove #> [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(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)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-PatServerUri -Uri $_ })] [string] $ServerUri, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $Token ) 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 { # Resolve destination to absolute path early $resolvedDestination = [System.IO.Path]::GetFullPath($Destination) Write-Verbose "Resolved destination path: $resolvedDestination" # Get the playlist $playlistParams = @{ IncludeItems = $true ErrorAction = 'Stop' } if ($effectiveUri -and -not $ServerUri) { # Using default server, don't pass ServerUri } elseif ($ServerUri) { $playlistParams['ServerUri'] = $ServerUri if ($Token) { $playlistParams['Token'] = $Token } } if ($PlaylistId) { $playlistParams['PlaylistId'] = $PlaylistId } else { $playlistParams['PlaylistName'] = $PlaylistName } Write-Verbose "Retrieving playlist..." $playlist = Get-PatPlaylist @playlistParams if (-not $playlist) { throw "Playlist not found" } Write-Verbose "Playlist '$($playlist.Title)' has $($playlist.ItemCount) items" # Get media info for each playlist item (cache results to avoid redundant API calls) $addOperations = @() $totalBytesToDownload = 0 $mediaInfoCache = @{} if ($playlist.Items -and $playlist.Items.Count -gt 0) { $itemCount = 0 foreach ($item in $playlist.Items) { $itemCount++ Write-Verbose "Analyzing item $itemCount of $($playlist.Items.Count): $($item.Title)" $mediaInfoParams = @{ RatingKey = $item.RatingKey ErrorAction = 'Stop' } if ($ServerUri) { $mediaInfoParams['ServerUri'] = $ServerUri } if ($Token) { $mediaInfoParams['Token'] = $Token } $mediaInfo = Get-PatMediaInfo @mediaInfoParams # Cache media info for reuse when building expected paths $mediaInfoCache[$item.RatingKey] = $mediaInfo if (-not $mediaInfo.Media -or $mediaInfo.Media.Count -eq 0) { Write-Warning "No media files found for '$($item.Title)'" continue } # Use the first media version (default behavior) $media = $mediaInfo.Media[0] if (-not $media.Part -or $media.Part.Count -eq 0) { Write-Warning "No media parts found for '$($item.Title)'" continue } $part = $media.Part[0] # Determine destination path $extension = if ($part.Container) { $part.Container } else { 'mkv' } $destPath = Get-PatMediaPath -MediaInfo $mediaInfo -BasePath $resolvedDestination -Extension $extension # Check if file already exists with correct size $needsDownload = $true if (Test-Path -Path $destPath) { $existingFile = Get-Item -Path $destPath if ($existingFile.Length -eq $part.Size) { $needsDownload = $false Write-Verbose "File already exists with correct size: $destPath" } else { Write-Verbose "File exists but size mismatch: $destPath" } } if ($needsDownload) { # Count external subtitles $subtitleCount = 0 if ($part.Streams) { $subtitleCount = ($part.Streams | Where-Object { $_.StreamType -eq 3 -and $_.External }).Count } $addOperations += [PSCustomObject]@{ PSTypeName = 'PlexAutomationToolkit.SyncAddOperation' RatingKey = $mediaInfo.RatingKey Title = $mediaInfo.Title Type = $mediaInfo.Type Year = $mediaInfo.Year GrandparentTitle = $mediaInfo.GrandparentTitle ParentIndex = $mediaInfo.ParentIndex Index = $mediaInfo.Index DestinationPath = $destPath MediaSize = $part.Size SubtitleCount = $subtitleCount PartKey = $part.Key Container = $part.Container } $totalBytesToDownload += $part.Size } } } # Scan destination for files to remove (items not in playlist) $removeOperations = @() $totalBytesToRemove = 0 $moviesPath = [System.IO.Path]::Combine($resolvedDestination, 'Movies') $tvPath = [System.IO.Path]::Combine($resolvedDestination, 'TV Shows') # Get all expected paths from playlist $expectedPaths = @{} foreach ($op in $addOperations) { $expectedPaths[$op.DestinationPath] = $true } # Also mark existing items that don't need download (use cached media info) if ($playlist.Items) { foreach ($item in $playlist.Items) { # Use cached media info instead of making another API call $mediaInfo = $mediaInfoCache[$item.RatingKey] if ($mediaInfo -and $mediaInfo.Media -and $mediaInfo.Media.Count -gt 0) { $media = $mediaInfo.Media[0] if ($media.Part -and $media.Part.Count -gt 0) { $extension = if ($media.Part[0].Container) { $media.Part[0].Container } else { 'mkv' } $destPath = Get-PatMediaPath -MediaInfo $mediaInfo -BasePath $resolvedDestination -Extension $extension $expectedPaths[$destPath] = $true } } } } # Find files to remove in Movies folder if (Test-Path -Path $moviesPath) { $movieFiles = Get-ChildItem -Path $moviesPath -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '\.(mkv|mp4|avi|m4v|mov|ts|wmv)$' } foreach ($file in $movieFiles) { if (-not $expectedPaths.ContainsKey($file.FullName)) { $removeOperations += [PSCustomObject]@{ PSTypeName = 'PlexAutomationToolkit.SyncRemoveOperation' Path = $file.FullName Size = $file.Length Type = 'movie' } $totalBytesToRemove += $file.Length } } } # Find files to remove in TV Shows folder if (Test-Path -Path $tvPath) { $tvFiles = Get-ChildItem -Path $tvPath -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '\.(mkv|mp4|avi|m4v|mov|ts|wmv)$' } foreach ($file in $tvFiles) { if (-not $expectedPaths.ContainsKey($file.FullName)) { $removeOperations += [PSCustomObject]@{ PSTypeName = 'PlexAutomationToolkit.SyncRemoveOperation' Path = $file.FullName Size = $file.Length Type = 'episode' } $totalBytesToRemove += $file.Length } } } # Get destination drive info $destinationFree = 0 try { # Handle both drive letters and UNC paths if ($resolvedDestination -match '^([A-Z]):') { $driveLetter = $Matches[1] $drive = Get-PSDrive -Name $driveLetter -ErrorAction Stop $destinationFree = $drive.Free } else { # For UNC paths or when drive info isn't available, try filesystem info $driveInfo = [System.IO.DriveInfo]::GetDrives() | Where-Object { $resolvedDestination.StartsWith($_.Name, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 if ($driveInfo) { $destinationFree = $driveInfo.AvailableFreeSpace } } } catch { Write-Warning "Could not determine free space at destination: $($_.Exception.Message)" } # Calculate projected space $spaceNeeded = $totalBytesToDownload - $totalBytesToRemove $destinationAfter = $destinationFree - $spaceNeeded $spaceSufficient = $destinationAfter -ge 0 # Count unchanged items $itemsUnchanged = 0 if ($playlist.Items) { $itemsUnchanged = $playlist.Items.Count - $addOperations.Count } # Build sync plan $syncPlan = [PSCustomObject]@{ PSTypeName = 'PlexAutomationToolkit.SyncPlan' PlaylistName = $playlist.Title PlaylistId = $playlist.PlaylistId Destination = $resolvedDestination TotalItems = if ($playlist.Items) { $playlist.Items.Count } else { 0 } ItemsToAdd = $addOperations.Count ItemsToRemove = $removeOperations.Count ItemsUnchanged = $itemsUnchanged BytesToDownload = $totalBytesToDownload BytesToRemove = $totalBytesToRemove DestinationFree = $destinationFree DestinationAfter = $destinationAfter SpaceSufficient = $spaceSufficient AddOperations = $addOperations RemoveOperations = $removeOperations ServerUri = $effectiveUri } $syncPlan } catch { throw "Failed to generate sync plan: $($_.Exception.Message)" } } } |