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 ServerName
        The name of a stored server to use. Use Get-PatStoredServer to see available servers.
        This is more convenient than ServerUri as you don't need to remember the URI or token.
 
    .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 -Destination 'E:\' -ServerName 'Home'
 
        Shows sync plan for the 'Travel' playlist on the stored server named 'Home'.
 
    .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
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [string]
        $PlaylistName = 'Travel',

        [Parameter(Mandatory = $true, ParameterSetName = 'ById')]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $PlaylistId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Destination,

        [Parameter(Mandatory = $false)]
        [string]
        $ServerName,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-PatServerUri -Uri $_ })]
        [string]
        $ServerUri,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Token
    )

    begin {
        try {
            $script:serverContext = Resolve-PatServerContext -ServerName $ServerName -ServerUri $ServerUri -Token $Token
        }
        catch {
            throw "Failed to resolve server: $($_.Exception.Message)"
        }

        $effectiveUri = $script:serverContext.Uri
    }

    process {
        try {
            # Resolve destination to absolute path early
            $resolvedDestination = [System.IO.Path]::GetFullPath($Destination)
            Write-Verbose "Resolved destination path: $resolvedDestination"

            # Get the playlist - build parameters based on server context
            $serverSplat = Build-PatServerSplat -WasExplicitUri $script:serverContext.WasExplicitUri `
                -ServerUri $ServerUri -Token $Token -ServerName $ServerName
            $playlistParameters = @{
                IncludeItems = $true
                ErrorAction  = 'Stop'
            } + $serverSplat

            if ($PlaylistId) {
                $playlistParameters['PlaylistId'] = $PlaylistId
            }
            else {
                $playlistParameters['PlaylistName'] = $PlaylistName
            }

            Write-Verbose "Retrieving playlist..."
            $playlist = Get-PatPlaylist @playlistParameters

            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 = [System.Collections.Generic.List[PSCustomObject]]::new()
            $totalBytesToDownload = 0
            $mediaInformationCache = @{}

            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)"

                    $mediaInformationParameters = @{
                        RatingKey   = $item.RatingKey
                        ErrorAction = 'Stop'
                    } + $serverSplat

                    $mediaInformation = Get-PatMediaInfo @mediaInformationParameters

                    # Cache media info for reuse when building expected paths
                    $mediaInformationCache[$item.RatingKey] = $mediaInformation

                    # Check if this item needs to be downloaded
                    $addOperation = Get-PatSyncAddOperation -MediaInfo $mediaInformation -BasePath $resolvedDestination

                    if ($addOperation) {
                        $addOperations.Add($addOperation)
                        $totalBytesToDownload += $addOperation.MediaSize
                    }
                    elseif (-not $mediaInformation.Media -or $mediaInformation.Media.Count -eq 0) {
                        Write-Warning "No media files found for '$($item.Title)'"
                    }
                    elseif (-not $mediaInformation.Media[0].Part -or $mediaInformation.Media[0].Part.Count -eq 0) {
                        Write-Warning "No media parts found for '$($item.Title)'"
                    }
                }
            }

            # Build expected paths from playlist items
            $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
                    $mediaInformation = $mediaInformationCache[$item.RatingKey]
                    if ($mediaInformation -and $mediaInformation.Media -and $mediaInformation.Media.Count -gt 0) {
                        $media = $mediaInformation.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 $mediaInformation -BasePath $resolvedDestination -Extension $extension
                            $expectedPaths[$destPath] = $true
                        }
                    }
                }
            }

            # Scan destination for files to remove (items not in playlist)
            $moviesPath = [System.IO.Path]::Combine($resolvedDestination, 'Movies')
            $tvPath = [System.IO.Path]::Combine($resolvedDestination, 'TV Shows')

            $movieRemoveResult = Get-PatSyncRemoveOperation -FolderPath $moviesPath -ExpectedPaths $expectedPaths -MediaType 'movie'
            $tvRemoveResult = Get-PatSyncRemoveOperation -FolderPath $tvPath -ExpectedPaths $expectedPaths -MediaType 'episode'

            $removeOperations = [System.Collections.Generic.List[PSCustomObject]]::new()
            if ($movieRemoveResult.Operations) {
                $movieRemoveResult.Operations | ForEach-Object { $removeOperations.Add($_) }
            }
            if ($tvRemoveResult.Operations) {
                $tvRemoveResult.Operations | ForEach-Object { $removeOperations.Add($_) }
            }
            $totalBytesToRemove = $movieRemoveResult.TotalBytes + $tvRemoveResult.TotalBytes

            # Get destination drive info
            $destinationFree = Get-PatDestinationFreeSpace -Path $resolvedDestination

            # 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)"
        }
    }
}