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. .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 ) 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 ($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 $addOperations = @() $totalBytesToDownload = 0 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 } $mediaInfo = Get-PatMediaInfo @mediaInfoParams 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 if ($playlist.Items) { foreach ($item in $playlist.Items) { $mediaInfoParams = @{ RatingKey = $item.RatingKey ErrorAction = 'SilentlyContinue' } if ($ServerUri) { $mediaInfoParams['ServerUri'] = $ServerUri } $mediaInfo = Get-PatMediaInfo @mediaInfoParams 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)" } } } |