Public/Update-PatLibrary.ps1

function Update-PatLibrary {
    <#
    .SYNOPSIS
        Refreshes a Plex library section.
 
    .DESCRIPTION
        Triggers a refresh scan on a specified Plex library section.
        Optionally scans a specific path within the library.
        You can specify the section by ID or by friendly name.
 
    .PARAMETER ServerUri
        The base URI of the Plex server (e.g., http://plex.example.com:32400)
        If not specified, uses the default stored server.
 
    .PARAMETER SectionId
        The ID of the library section to refresh
 
    .PARAMETER SectionName
        The friendly name of the library section to refresh (e.g., "Movies", "TV Shows")
 
    .PARAMETER Path
        Optional path within the library to scan. If omitted, the entire section is scanned.
 
    .PARAMETER PassThru
        If specified, returns the library section object after refreshing.
 
    .PARAMETER SkipPathValidation
        If specified, skips validation that the path exists before triggering the refresh.
        Use when you know the path is valid or want to scan a path that may not be browsable.
 
    .PARAMETER Wait
        If specified, waits for the library scan to complete before returning.
 
    .PARAMETER Timeout
        Maximum time in seconds to wait for the scan to complete when using -Wait.
        Default is 300 seconds (5 minutes).
 
    .PARAMETER ReportChanges
        If specified, returns a report of changes detected during the scan.
        Automatically enables -Wait behavior.
 
    .EXAMPLE
        Update-PatLibrary -ServerUri "http://plex.example.com:32400" -SectionId 2
 
        Refreshes the entire library section 2.
 
    .EXAMPLE
        Update-PatLibrary -SectionName "Movies"
 
        Refreshes the "Movies" library section on the default stored server.
 
    .EXAMPLE
        Update-PatLibrary -ServerUri "http://plex.example.com:32400" -SectionName "Movies"
 
        Refreshes the library section named "Movies".
 
    .EXAMPLE
        Update-PatLibrary -ServerUri "http://plex.example.com:32400" -SectionId 2 -Path "/mnt/media/Movies"
 
        Refreshes only the specified path within library section 2.
 
    .EXAMPLE
        Update-PatLibrary -SectionId 2 -Path "/mnt/media/Movies"
 
        Refreshes only the specified path within library section 2 on the default stored server.
 
    .EXAMPLE
        Update-PatLibrary -ServerUri "http://plex.example.com:32400" -SectionName "Movies" -Path "/mnt/media/Movies/Action"
 
        Refreshes only the specified path within the "Movies" library section.
 
    .EXAMPLE
        Update-PatLibrary -ServerUri "http://plex.example.com:32400" -SectionId 2 -WhatIf
 
        Shows what would happen if the command runs without actually refreshing the library.
 
    .EXAMPLE
        Update-PatLibrary -SectionId 2 -PassThru
 
        Refreshes library section 2 and returns the library object.
 
    .EXAMPLE
        Update-PatLibrary -SectionName 'Movies' -Path '/mnt/media/Movies/NewMovie'
 
        Validates that the path exists (default behavior), then triggers the refresh.
        Throws an error if the path is invalid or not within the library's configured paths.
 
    .EXAMPLE
        Update-PatLibrary -SectionName 'Movies' -Path '/mnt/media/Movies/NewMovie' -SkipPathValidation
 
        Skips path validation and triggers the refresh directly. Use when you know the
        path is valid or want to scan a path that may not be browsable.
 
    .EXAMPLE
        Update-PatLibrary -SectionName 'Movies' -Path '/mnt/media/Movies/NewMovie' -Wait
 
        Validates the path, triggers the refresh, and waits for the scan to complete.
 
    .EXAMPLE
        Update-PatLibrary -SectionName 'Movies' -Path '/mnt/media/Movies/NewMovie' -Wait -Timeout 60
 
        Validates the path, triggers the refresh, and waits up to 60 seconds for completion.
 
    .EXAMPLE
        $changes = Update-PatLibrary -SectionName 'Movies' -Path '/mnt/media/Movies/NewMovie' -ReportChanges
        $changes | Where-Object ChangeType -eq 'Added'
 
        Returns a list of items that were added or removed by the scan.
    #>

    [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', SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

            # Strip leading quotes for matching (case-insensitive)
            $quoteChar = ''
            $strippedWord = $wordToComplete
            if ($wordToComplete -match "^([`"'])(.*)$") {
                $quoteChar = $Matches[1]
                $strippedWord = $Matches[2]
            }

            # Use provided ServerUri if available, otherwise use default server
            if ($fakeBoundParameters.ContainsKey('ServerUri')) {
                try {
                    $sections = Get-PatLibrary -ServerUri $fakeBoundParameters['ServerUri'] -ErrorAction 'SilentlyContinue'
                    foreach ($sectionTitle in $sections.Directory.title) {
                        if ($sectionTitle -ilike "$strippedWord*") {
                            if ($quoteChar) { $completionText = "$quoteChar$sectionTitle$quoteChar" }
                            elseif ($sectionTitle -match '\s') { $completionText = "'$sectionTitle'" }
                            else { $completionText = $sectionTitle }
                            [System.Management.Automation.CompletionResult]::new($completionText, $sectionTitle, 'ParameterValue', $sectionTitle)
                        }
                    }
                }
                catch {
                    Write-Debug "Tab completion failed for SectionName: $($_.Exception.Message)"
                }
            }
            else {
                # Fall back to default server - don't pass ServerUri so Get-PatLibrary retrieves server object with token
                try {
                    $sections = Get-PatLibrary -ErrorAction 'SilentlyContinue'
                    foreach ($sectionTitle in $sections.Directory.title) {
                        if ($sectionTitle -ilike "$strippedWord*") {
                            if ($quoteChar) { $completionText = "$quoteChar$sectionTitle$quoteChar" }
                            elseif ($sectionTitle -match '\s') { $completionText = "'$sectionTitle'" }
                            else { $completionText = $sectionTitle }
                            [System.Management.Automation.CompletionResult]::new($completionText, $sectionTitle, 'ParameterValue', $sectionTitle)
                        }
                    }
                }
                catch {
                    Write-Debug "Tab completion failed for SectionName (default server): $($_.Exception.Message)"
                }
            }
        })]
        [string]
        $SectionName,

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

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

            # Strip leading quotes for matching (supports 'value and "value)
            $quoteChar = ''
            $strippedWord = $wordToComplete
            if ($wordToComplete -match "^([`"'])(.*)$") {
                $quoteChar = $Matches[1]
                $strippedWord = $Matches[2]
            }

            # Check if ServerUri was explicitly provided
            $usingDefaultServer = -not $fakeBoundParameters.ContainsKey('ServerUri')

            # If using default server, verify it exists
            if ($usingDefaultServer) {
                try {
                    $defaultServer = Get-PatStoredServer -Default -ErrorAction 'Stop'
                    if (-not $defaultServer) { return }
                }
                catch {
                    Write-Debug "Tab completion failed: Could not retrieve default server"
                    return
                }
            }

            # Get SectionId - could be direct or via SectionName
            $sectionId = $null
            if ($fakeBoundParameters.ContainsKey('SectionId')) {
                $sectionId = $fakeBoundParameters['SectionId']
            }
            elseif ($fakeBoundParameters.ContainsKey('SectionName')) {
                try {
                    if ($usingDefaultServer) {
                        $sections = Get-PatLibrary -ErrorAction 'SilentlyContinue'
                    }
                    else {
                        $sections = Get-PatLibrary -ServerUri $fakeBoundParameters['ServerUri'] -ErrorAction 'SilentlyContinue'
                    }
                    $matchedSection = $sections.Directory | Where-Object { $_.title -eq $fakeBoundParameters['SectionName'] }
                    if ($matchedSection) {
                        $sectionId = [int]($matchedSection.key -replace '.*/(\d+)$', '$1')
                    }
                }
                catch {
                    Write-Debug "Tab completion failed: Could not resolve section name to ID: $($_.Exception.Message)"
                }
            }

            if (-not $sectionId) { return }

            # Get root paths for this section
            try {
                if ($usingDefaultServer) {
                    $rootPaths = Get-PatLibraryPath -SectionId $sectionId -ErrorAction 'SilentlyContinue'
                }
                else {
                    $rootPaths = Get-PatLibraryPath -ServerUri $fakeBoundParameters['ServerUri'] -SectionId $sectionId -ErrorAction 'SilentlyContinue'
                }

                # Helper to create completion result with proper quoting
                $createCompletion = {
                    param($value)
                    if ($value -ilike "$strippedWord*") {
                        if ($quoteChar) { $text = "$quoteChar$value$quoteChar" }
                        elseif ($value -match '\s') { $text = "'$value'" }
                        else { $text = $value }
                        [System.Management.Automation.CompletionResult]::new($text, $value, 'ParameterValue', $value)
                    }
                }

                if (-not $strippedWord) {
                    # No input yet - show root paths
                    foreach ($rootPath in $rootPaths) {
                        $result = & $createCompletion $rootPath.path
                        if ($result) { $result }
                    }
                }
                else {
                    # Determine the path to browse
                    # If strippedWord exactly matches a root path, browse that path
                    # Otherwise, get the parent directory manually (preserve Unix paths)
                    $exactRoot = $rootPaths | Where-Object { $_.path -ieq $strippedWord }
                    $pathToBrowse = if ($exactRoot) {
                        $strippedWord
                    } else {
                        # Manual parent path extraction to preserve forward slashes
                        # Split-Path on Windows converts /foo/bar to \foo\bar which breaks Linux paths
                        $lastSlash = [Math]::Max($strippedWord.LastIndexOf('/'), $strippedWord.LastIndexOf('\'))
                        if ($lastSlash -gt 0) { $strippedWord.Substring(0, $lastSlash) } else { $null }
                    }

                    $browsedItems = $false
                    if ($pathToBrowse) {
                        try {
                            if ($usingDefaultServer) {
                                $items = Get-PatLibraryChildItem -Path $pathToBrowse -ErrorAction 'SilentlyContinue'
                            }
                            else {
                                $items = Get-PatLibraryChildItem -ServerUri $fakeBoundParameters['ServerUri'] -Path $pathToBrowse -ErrorAction 'SilentlyContinue'
                            }

                            if ($items) {
                                $browsedItems = $true
                                foreach ($item in $items) {
                                    # Get the path property (handle both 'path' and 'Path' casing)
                                    $itemPath = if ($item.PSObject.Properties['path']) { $item.path } elseif ($item.PSObject.Properties['Path']) { $item.Path } else { $null }
                                    if ($itemPath) {
                                        $result = & $createCompletion $itemPath
                                        if ($result) { $result }
                                    }
                                }
                            }
                        }
                        catch {
                            Write-Debug "Tab completion failed: Could not browse path: $($_.Exception.Message)"
                        }
                    }

                    # Fall back to matching root paths if browsing didn't work
                    if (-not $browsedItems) {
                        $matchingRoots = $rootPaths | Where-Object { $_.path -ilike "$strippedWord*" }
                        foreach ($rootPath in $matchingRoots) {
                            $result = & $createCompletion $rootPath.path
                            if ($result) { $result }
                        }
                    }
                }
            }
            catch {
                Write-Debug "Tab completion failed: Could not retrieve library paths: $($_.Exception.Message)"
            }
        })]
        [string]
        $Path,

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [switch]
        $PassThru,

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [switch]
        $SkipPathValidation,

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [switch]
        $Wait,

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [ValidateRange(1, 3600)]
        [int]
        $Timeout = 300,

        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [switch]
        $ReportChanges,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ById')]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
            if ($_ -notmatch '^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$') {
                throw "ServerUri must be a valid HTTP or HTTPS URL (e.g., http://plex.local:32400)"
            }
            $true
        })]
        [string]
        $ServerUri
    )

    # Use default server if ServerUri not specified
    $server = $null
    $effectiveUri = $ServerUri
    $usingDefaultServer = $false
    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
            $usingDefaultServer = $true
        }
        catch {
            throw "Failed to get default server: $($_.Exception.Message)"
        }
    }

    # If using section name, resolve it to section ID
    $resolvedSectionId = $SectionId
    if ($PSCmdlet.ParameterSetName -eq 'ByName') {
        try {
            # If using default server, don't pass ServerUri so Get-PatLibrary can retrieve server object with token
            if ($usingDefaultServer) {
                $sections = Get-PatLibrary -ErrorAction 'Stop'
            }
            else {
                $sections = Get-PatLibrary -ServerUri $effectiveUri -ErrorAction 'Stop'
            }
            $matchedSection = $sections.Directory | Where-Object { $_.title -eq $SectionName }

            if (-not $matchedSection) {
                throw "No library section found with name '$SectionName'"
            }

            if ($matchedSection -is [array]) {
                throw "Multiple library sections found with name '$SectionName'. Please use -SectionId instead."
            }

            $resolvedSectionId = [int]($matchedSection.key -replace '.*/(\d+)$', '$1')
        }
        catch {
            throw "Failed to resolve section name: $($_.Exception.Message)"
        }
    }

    # Pre-validation: Check if path exists (default behavior, skip with -SkipPathValidation)
    if ($Path -and -not $SkipPathValidation) {
        Write-Verbose "Validating path: $Path"
        $testParams = @{ Path = $Path }
        if ($effectiveUri) { $testParams['ServerUri'] = $effectiveUri }
        if ($resolvedSectionId) { $testParams['SectionId'] = $resolvedSectionId }

        $pathValid = Test-PatLibraryPath @testParams
        if (-not $pathValid) {
            throw "Path validation failed: '$Path' does not exist or is not within the library's configured paths. Use -SkipPathValidation to bypass this check."
        }
        Write-Verbose "Path validation passed"
    }

    # Capture before state if we need to report changes
    $beforeItems = $null
    if ($ReportChanges) {
        Write-Verbose "Capturing library state before scan"
        $getItemParams = @{ SectionId = $resolvedSectionId }
        if ($effectiveUri) { $getItemParams['ServerUri'] = $effectiveUri }
        $beforeItems = @(Get-PatLibraryItem @getItemParams -ErrorAction 'SilentlyContinue')
        Write-Verbose "Captured $($beforeItems.Count) items before scan"
    }

    $endpoint = "/library/sections/$resolvedSectionId/refresh"
    $queryString = $null

    if ($Path) {
        $queryString = "path=$([System.Uri]::EscapeDataString($Path))"
    }

    $uri = Join-PatUri -BaseUri $effectiveUri -Endpoint $endpoint -QueryString $queryString

    if ($Path) {
        $target = "section $resolvedSectionId path '$Path'"
    }
    else {
        $target = "section $resolvedSectionId"
    }

    # Build headers with authentication if we have server object
    $headers = if ($server) {
        Get-PatAuthHeaders -Server $server
    }
    else {
        @{ Accept = 'application/json' }
    }

    if ($PSCmdlet.ShouldProcess($target, 'Refresh library')) {
        try {
            Invoke-PatApi -Uri $uri -Method 'Post' -Headers $headers -ErrorAction 'Stop'

            # Wait for scan to complete if requested
            if ($Wait -or $ReportChanges) {
                Write-Verbose "Waiting for scan to complete (timeout: ${Timeout}s)"
                $waitParams = @{
                    SectionId       = $resolvedSectionId
                    Timeout         = $Timeout
                    PollingInterval = 2
                }
                if ($effectiveUri) { $waitParams['ServerUri'] = $effectiveUri }

                Wait-PatLibraryScan @waitParams
                Write-Verbose "Scan completed"
            }

            # Report changes if requested
            if ($ReportChanges) {
                Write-Verbose "Capturing library state after scan"
                $getItemParams = @{ SectionId = $resolvedSectionId }
                if ($effectiveUri) { $getItemParams['ServerUri'] = $effectiveUri }
                $afterItems = @(Get-PatLibraryItem @getItemParams -ErrorAction 'SilentlyContinue')
                Write-Verbose "Captured $($afterItems.Count) items after scan"

                $changes = Compare-PatLibraryContent -Before $beforeItems -After $afterItems
                $changes
            }
            elseif ($PassThru) {
                # Return the refreshed library section
                if ($usingDefaultServer) {
                    Get-PatLibrary -SectionId $resolvedSectionId -ErrorAction 'Stop'
                }
                else {
                    Get-PatLibrary -ServerUri $effectiveUri -SectionId $resolvedSectionId -ErrorAction 'Stop'
                }
            }
        }
        catch {
            throw "Failed to refresh Plex library: $($_.Exception.Message)"
        }
    }
}