Public/Compare-PatWatchStatus.ps1

function Compare-PatWatchStatus {
    <#
    .SYNOPSIS
        Compares watch status between two Plex servers.
 
    .DESCRIPTION
        Queries both source and target Plex servers and identifies items with different
        watch status. Matches items by title and year for movies, and by show name,
        season, and episode number for TV episodes.
 
    .PARAMETER SourceServerName
        The name of the source server (as stored with Add-PatServer).
 
    .PARAMETER TargetServerName
        The name of the target server (as stored with Add-PatServer).
 
    .PARAMETER SectionId
        Optional array of library section IDs to compare. If not specified, compares all sections.
 
    .PARAMETER WatchedOnSourceOnly
        When specified, only returns items that are watched on source but not on target.
 
    .PARAMETER WatchedOnTargetOnly
        When specified, only returns items that are watched on target but not on source.
 
    .EXAMPLE
        Compare-PatWatchStatus -SourceServerName 'Travel' -TargetServerName 'Home'
 
        Compares watch status between the Travel and Home servers.
 
    .EXAMPLE
        Compare-PatWatchStatus -SourceServerName 'Travel' -TargetServerName 'Home' -WatchedOnSourceOnly
 
        Shows items watched on Travel server but not on Home server.
 
    .OUTPUTS
        PlexAutomationToolkit.WatchStatusDiff
 
        Objects with properties:
        - Title: Item title
        - Type: 'movie' or 'episode'
        - Year: Release year (movies)
        - ShowName: Series name (episodes)
        - Season: Season number (episodes)
        - Episode: Episode number (episodes)
        - SourceWatched: Whether watched on source server
        - TargetWatched: Whether watched on target server
        - SourceViewCount: View count on source
        - TargetViewCount: View count on target
        - SourceRatingKey: Rating key on source server
        - TargetRatingKey: Rating key on target server
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject], [object[]])]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $SourceServerName,

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

        [Parameter(Mandatory = $false)]
        [int[]]
        $SectionId,

        [Parameter(Mandatory = $false)]
        [switch]
        $WatchedOnSourceOnly,

        [Parameter(Mandatory = $false)]
        [switch]
        $WatchedOnTargetOnly
    )

    begin {
        # Get server configurations
        try {
            $sourceServer = Get-PatStoredServer -Name $SourceServerName -ErrorAction Stop
            if (-not $sourceServer) {
                throw "Source server '$SourceServerName' not found. Use Add-PatServer to configure it."
            }

            $targetServer = Get-PatStoredServer -Name $TargetServerName -ErrorAction Stop
            if (-not $targetServer) {
                throw "Target server '$TargetServerName' not found. Use Add-PatServer to configure it."
            }
        }
        catch {
            throw "Failed to get server configuration: $($_.Exception.Message)"
        }

        Write-Verbose "Comparing watch status between '$SourceServerName' and '$TargetServerName'"
    }

    process {
        try {
            # Get library sections from both servers
            $sourceSections = Get-PatLibrary -ServerUri $sourceServer.uri -ErrorAction Stop
            $targetSections = Get-PatLibrary -ServerUri $targetServer.uri -ErrorAction Stop

            if (-not $sourceSections.Directory) {
                throw "No library sections found on source server"
            }
            if (-not $targetSections.Directory) {
                throw "No library sections found on target server"
            }

            # Filter sections if specified
            $sourceSectionsToCompare = $sourceSections.Directory
            $targetSectionsToCompare = $targetSections.Directory

            if ($SectionId) {
                $sourceSectionsToCompare = $sourceSectionsToCompare | Where-Object {
                    ($_.key -replace '.*/(\d+)$', '$1') -in $SectionId
                }
                $targetSectionsToCompare = $targetSectionsToCompare | Where-Object {
                    ($_.key -replace '.*/(\d+)$', '$1') -in $SectionId
                }
            }

            # Only compare movie and show sections
            $sourceSectionsToCompare = $sourceSectionsToCompare | Where-Object { $_.type -in 'movie', 'show' }
            $targetSectionsToCompare = $targetSectionsToCompare | Where-Object { $_.type -in 'movie', 'show' }

            # Build lookup of target items by match key
            $targetItems = @{}

            foreach ($section in $targetSectionsToCompare) {
                $currentSectionId = [int]($section.key -replace '.*/(\d+)$', '$1')
                Write-Verbose "Scanning target section: $($section.title) (ID: $currentSectionId)"

                $items = Get-PatLibraryItem -SectionId $currentSectionId -ServerUri $targetServer.uri -ErrorAction SilentlyContinue

                if ($section.type -eq 'movie') {
                    foreach ($item in $items) {
                        $matchKey = Get-WatchStatusMatchKey -Type 'movie' -Title $item.title -Year $item.year
                        $targetItems[$matchKey] = @{
                            RatingKey = [int]$item.ratingKey
                            Title     = $item.title
                            Year      = $item.year
                            ViewCount = if ($item.viewCount) { [int]$item.viewCount } else { 0 }
                            Watched   = ($item.viewCount -gt 0)
                        }
                    }
                }
                elseif ($section.type -eq 'show') {
                    # For TV shows, we need to get episodes
                    foreach ($show in $items) {
                        $showRatingKey = $show.ratingKey
                        $showTitle = $show.title

                        # Get all episodes for this show
                        $episodesUri = Join-PatUri -BaseUri $targetServer.uri -Endpoint "/library/metadata/$showRatingKey/allLeaves"
                        $headers = Get-PatAuthenticationHeader -Server $targetServer
                        $episodesResult = Invoke-PatApi -Uri $episodesUri -Headers $headers -ErrorAction SilentlyContinue

                        if ($episodesResult.Metadata) {
                            foreach ($ep in $episodesResult.Metadata) {
                                $matchKey = Get-WatchStatusMatchKey -Type 'episode' -ShowName $showTitle `
                                    -Season $ep.parentIndex -Episode $ep.index

                                $targetItems[$matchKey] = @{
                                    RatingKey = [int]$ep.ratingKey
                                    Title     = $ep.title
                                    ShowName  = $showTitle
                                    Season    = [int]$ep.parentIndex
                                    Episode   = [int]$ep.index
                                    ViewCount = if ($ep.viewCount) { [int]$ep.viewCount } else { 0 }
                                    Watched   = ($ep.viewCount -gt 0)
                                }
                            }
                        }
                    }
                }
            }

            Write-Verbose "Found $($targetItems.Count) items on target server"

            # Compare source items against target
            $differences = @()

            foreach ($section in $sourceSectionsToCompare) {
                $currentSectionId = [int]($section.key -replace '.*/(\d+)$', '$1')
                Write-Verbose "Scanning source section: $($section.title) (ID: $currentSectionId)"

                $items = Get-PatLibraryItem -SectionId $currentSectionId -ServerUri $sourceServer.uri -ErrorAction SilentlyContinue

                if ($section.type -eq 'movie') {
                    foreach ($item in $items) {
                        $matchKey = Get-WatchStatusMatchKey -Type 'movie' -Title $item.title -Year $item.year
                        $sourceWatched = ($item.viewCount -gt 0)
                        $sourceViewCount = if ($item.viewCount) { [int]$item.viewCount } else { 0 }

                        if ($targetItems.ContainsKey($matchKey)) {
                            $target = $targetItems[$matchKey]

                            # Check if watch status differs
                            if ($sourceWatched -ne $target.Watched) {
                                # Apply filters
                                if ($WatchedOnSourceOnly -and -not ($sourceWatched -and -not $target.Watched)) {
                                    continue
                                }
                                if ($WatchedOnTargetOnly -and -not (-not $sourceWatched -and $target.Watched)) {
                                    continue
                                }

                                $differences += [PSCustomObject]@{
                                    PSTypeName       = 'PlexAutomationToolkit.WatchStatusDiff'
                                    Title            = $item.title
                                    Type             = 'movie'
                                    Year             = $item.year
                                    ShowName         = $null
                                    Season           = $null
                                    Episode          = $null
                                    SourceWatched    = $sourceWatched
                                    TargetWatched    = $target.Watched
                                    SourceViewCount  = $sourceViewCount
                                    TargetViewCount  = $target.ViewCount
                                    SourceRatingKey  = [int]$item.ratingKey
                                    TargetRatingKey  = $target.RatingKey
                                }
                            }
                        }
                    }
                }
                elseif ($section.type -eq 'show') {
                    foreach ($show in $items) {
                        $showRatingKey = $show.ratingKey
                        $showTitle = $show.title

                        # Get all episodes for this show
                        $episodesUri = Join-PatUri -BaseUri $sourceServer.uri -Endpoint "/library/metadata/$showRatingKey/allLeaves"
                        $headers = Get-PatAuthenticationHeader -Server $sourceServer
                        $episodesResult = Invoke-PatApi -Uri $episodesUri -Headers $headers -ErrorAction SilentlyContinue

                        if ($episodesResult.Metadata) {
                            foreach ($ep in $episodesResult.Metadata) {
                                $matchKey = Get-WatchStatusMatchKey -Type 'episode' -ShowName $showTitle `
                                    -Season $ep.parentIndex -Episode $ep.index

                                $sourceWatched = ($ep.viewCount -gt 0)
                                $sourceViewCount = if ($ep.viewCount) { [int]$ep.viewCount } else { 0 }

                                if ($targetItems.ContainsKey($matchKey)) {
                                    $target = $targetItems[$matchKey]

                                    if ($sourceWatched -ne $target.Watched) {
                                        # Apply filters
                                        if ($WatchedOnSourceOnly -and -not ($sourceWatched -and -not $target.Watched)) {
                                            continue
                                        }
                                        if ($WatchedOnTargetOnly -and -not (-not $sourceWatched -and $target.Watched)) {
                                            continue
                                        }

                                        $differences += [PSCustomObject]@{
                                            PSTypeName       = 'PlexAutomationToolkit.WatchStatusDiff'
                                            Title            = $ep.title
                                            Type             = 'episode'
                                            Year             = $null
                                            ShowName         = $showTitle
                                            Season           = [int]$ep.parentIndex
                                            Episode          = [int]$ep.index
                                            SourceWatched    = $sourceWatched
                                            TargetWatched    = $target.Watched
                                            SourceViewCount  = $sourceViewCount
                                            TargetViewCount  = $target.ViewCount
                                            SourceRatingKey  = [int]$ep.ratingKey
                                            TargetRatingKey  = $target.RatingKey
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            Write-Verbose "Found $($differences.Count) items with different watch status"

            $differences
        }
        catch {
            throw "Failed to compare watch status: $($_.Exception.Message)"
        }
    }
}

# Private helper function for generating match keys
function Get-WatchStatusMatchKey {
    param (
        [string]$Type,
        [string]$Title,
        [int]$Year,
        [string]$ShowName,
        [int]$Season,
        [int]$Episode
    )

    # Normalize title for comparison
    $normalizedTitle = if ($Title) {
        $Title.ToLowerInvariant().Trim() -replace '[^\w\s]', ''
    } else { '' }

    if ($Type -eq 'movie') {
        return "movie|$normalizedTitle|$Year"
    }
    elseif ($Type -eq 'episode') {
        $normalizedShow = if ($ShowName) {
            $ShowName.ToLowerInvariant().Trim() -replace '[^\w\s]', ''
        } else { '' }
        return "episode|$normalizedShow|S${Season}E${Episode}"
    }

    return "unknown|$normalizedTitle"
}