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

    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 ($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..."

                $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 = if ($server -and $server.token) { $server.token } else { $null }
                    $downloadUrl = if ($token) {
                        "$effectiveUri$($addOp.PartKey)?download=1&X-Plex-Token=$token"
                    }
                    else {
                        "$effectiveUri$($addOp.PartKey)?download=1"
                    }

                    Write-Verbose "Downloading: $($addOp.DestinationPath)"

                    try {
                        Invoke-PatFileDownload -Uri $downloadUrl `
                            -OutFile $addOp.DestinationPath `
                            -ExpectedSize $addOp.MediaSize `
                            -Resume `
                            -ErrorAction Stop | 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
                            }

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

                                    $subUrl = if ($token) {
                                        "$effectiveUri$($sub.Key)?download=1&X-Plex-Token=$token"
                                    }
                                    else {
                                        "$effectiveUri$($sub.Key)?download=1"
                                    }

                                    Write-Verbose "Downloading subtitle: $subPath"

                                    try {
                                        Invoke-PatFileDownload -Uri $subUrl `
                                            -OutFile $subPath `
                                            -ErrorAction Stop | 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"

            if ($PassThru) {
                $syncPlan
            }
        }
        catch {
            throw "Failed to sync media: $($_.Exception.Message)"
        }
    }
}