Private/ConvertTo-YtmSong.ps1

function ConvertTo-YtmSong {
    <#
    .SYNOPSIS
        Converts a YouTube Music API song response to a typed object.

    .DESCRIPTION
        Parses the raw API response for a song/track and converts it to a
        PSCustomObject with the YouTubeMusicPS.Song type name.

    .PARAMETER InputObject
        The raw song data from the API response

    .PARAMETER PlaylistId
        Optional playlist ID to include in the output object. Used when parsing
        playlist contents to enable pipeline operations like Remove-YtmPlaylistItem.

    .OUTPUTS
        YouTubeMusicPS.Song
        Custom object with song properties

    .EXAMPLE
        $songs = $response.contents | ForEach-Object { ConvertTo-YtmSong -InputObject $_ }

    .EXAMPLE
        $songs = $response.contents | ForEach-Object { ConvertTo-YtmSong -InputObject $_ -PlaylistId 'PLxxx' }
    #>

    [CmdletBinding()]
    [OutputType('YouTubeMusicPS.Song')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [PSCustomObject]$InputObject,

        [Parameter(Mandatory = $false)]
        [string]$PlaylistId
    )

    process {
        # Extract the musicResponsiveListItemRenderer if present
        $data = $InputObject
        if ($InputObject.PSObject.Properties['musicResponsiveListItemRenderer']) {
            $data = $InputObject.musicResponsiveListItemRenderer
        }

        # Extract video ID from playNavigationEndpoint or menu
        $videoId = $null
        if ($data.PSObject.Properties['overlay']) {
            $overlay = $data.overlay
            if ($overlay.PSObject.Properties['musicItemThumbnailOverlayRenderer']) {
                $overlayRenderer = $overlay.musicItemThumbnailOverlayRenderer
                if ($overlayRenderer.PSObject.Properties['content']) {
                    $overlayContent = $overlayRenderer.content
                    if ($overlayContent.PSObject.Properties['musicPlayButtonRenderer']) {
                        $playButton = $overlayContent.musicPlayButtonRenderer
                        if ($playButton.PSObject.Properties['playNavigationEndpoint']) {
                            $playEndpoint = $playButton.playNavigationEndpoint
                            if ($playEndpoint.PSObject.Properties['watchEndpoint']) {
                                $videoId = $playEndpoint.watchEndpoint.videoId
                            }
                        }
                    }
                }
            }
        }

        # Extract title from flexColumns
        $title = $null
        if ($data.PSObject.Properties['flexColumns'] -and $data.flexColumns.Count -gt 0) {
            $titleColumn = $data.flexColumns[0].musicResponsiveListItemFlexColumnRenderer
            if ($titleColumn.PSObject.Properties['text'] -and $titleColumn.text.PSObject.Properties['runs']) {
                $title = $titleColumn.text.runs[0].text
            }
        }

        # Extract artist from flexColumns (usually index 1)
        $artist = $null
        $artistId = $null
        if ($data.PSObject.Properties['flexColumns'] -and $data.flexColumns.Count -gt 1) {
            $artistColumn = $data.flexColumns[1].musicResponsiveListItemFlexColumnRenderer
            if ($artistColumn.PSObject.Properties['text'] -and $artistColumn.text.PSObject.Properties['runs']) {
                $artistRuns = $artistColumn.text.runs
                $artist = ($artistRuns | Where-Object { $_.PSObject.Properties['text'] } | ForEach-Object { $_.text }) -join ''
                # Get first artist ID if available
                $firstArtistRun = $artistRuns | Where-Object { $_.PSObject.Properties['navigationEndpoint'] } | Select-Object -First 1
                if ($firstArtistRun) {
                    $artistId = $firstArtistRun.navigationEndpoint.browseEndpoint.browseId
                }
            }
        }

        # Extract album from flexColumns (usually index 2 or 3)
        $album = $null
        $albumId = $null
        foreach ($i in 2..3) {
            if ($data.PSObject.Properties['flexColumns'] -and $data.flexColumns.Count -gt $i) {
                $albumColumn = $data.flexColumns[$i].musicResponsiveListItemFlexColumnRenderer
                if ($albumColumn.PSObject.Properties['text'] -and $albumColumn.text.PSObject.Properties['runs']) {
                    $albumRun = $albumColumn.text.runs | Where-Object {
                        $_.PSObject.Properties['navigationEndpoint'] -and
                        $_.navigationEndpoint.PSObject.Properties['browseEndpoint']
                    } | Select-Object -First 1
                    if ($albumRun) {
                        $browseEndpoint = $albumRun.navigationEndpoint.browseEndpoint
                        $pageType = $null
                        if ($browseEndpoint.PSObject.Properties['browseEndpointContextSupportedConfigs']) {
                            $contextConfigs = $browseEndpoint.browseEndpointContextSupportedConfigs
                            if ($contextConfigs.PSObject.Properties['browseEndpointContextMusicConfig']) {
                                $pageType = $contextConfigs.browseEndpointContextMusicConfig.pageType
                            }
                        }
                        if ($pageType -eq 'MUSIC_PAGE_TYPE_ALBUM') {
                            $album = $albumRun.text
                            $albumId = $browseEndpoint.browseId
                            break
                        }
                    }
                }
            }
        }

        # Extract duration from fixedColumns
        $duration = $null
        $durationSeconds = $null
        if ($data.PSObject.Properties['fixedColumns'] -and $data.fixedColumns.Count -gt 0) {
            $durationColumn = $data.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer
            if ($durationColumn.PSObject.Properties['text']) {
                if ($durationColumn.text.PSObject.Properties['simpleText']) {
                    $duration = $durationColumn.text.simpleText
                }
                elseif ($durationColumn.text.PSObject.Properties['runs']) {
                    $duration = $durationColumn.text.runs[0].text
                }
            }
        }

        # Parse duration to seconds
        if ($duration) {
            $parts = $duration -split ':'
            try {
                if ($parts.Count -eq 2) {
                    $durationSeconds = [int]$parts[0] * 60 + [int]$parts[1]
                }
                elseif ($parts.Count -eq 3) {
                    $durationSeconds = [int]$parts[0] * 3600 + [int]$parts[1] * 60 + [int]$parts[2]
                }
            }
            catch {
                Write-Verbose "Could not parse duration '$duration': $($_.Exception.Message)"
                $durationSeconds = $null
            }
        }

        # Extract thumbnail
        $thumbnailUrl = $null
        if ($data.PSObject.Properties['thumbnail']) {
            $thumbnails = $data.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
            if ($thumbnails -and $thumbnails.Count -gt 0) {
                # Get the largest thumbnail
                $thumbnailUrl = ($thumbnails | Sort-Object -Property width -Descending | Select-Object -First 1).url
            }
        }

        # Extract like status
        $likeStatus = $null
        if ($data.PSObject.Properties['menu']) {
            $menuItems = $data.menu.menuRenderer.items
            foreach ($item in $menuItems) {
                if ($item.PSObject.Properties['menuServiceItemRenderer']) {
                    $serviceEndpoint = $item.menuServiceItemRenderer.serviceEndpoint
                    if ($serviceEndpoint.PSObject.Properties['likeEndpoint']) {
                        $likeStatus = $serviceEndpoint.likeEndpoint.status
                        break
                    }
                }
            }
        }

        # Extract setVideoId for playlist items (required for removal operations)
        $setVideoId = $null
        if ($data.PSObject.Properties['playlistItemData']) {
            $playlistItemData = $data.playlistItemData
            if ($playlistItemData.PSObject.Properties['playlistSetVideoId']) {
                $setVideoId = $playlistItemData.playlistSetVideoId
            }
        }

        # Return typed object
        [PSCustomObject]@{
            PSTypeName      = 'YouTubeMusicPS.Song'
            VideoId         = $videoId
            SetVideoId      = $setVideoId
            PlaylistId      = if ($PlaylistId) { $PlaylistId } else { $null }
            Title           = $title
            Artist          = $artist
            ArtistId        = $artistId
            Album           = $album
            AlbumId         = $albumId
            Duration        = $duration
            DurationSeconds = $durationSeconds
            ThumbnailUrl    = $thumbnailUrl
            LikeStatus      = $likeStatus
        }
    }
}