Public/Remove-YtmPlaylistItem.ps1

function Remove-YtmPlaylistItem {
    <#
    .SYNOPSIS
        Removes a song from a YouTube Music playlist.

    .DESCRIPTION
        Removes one or more songs from a playlist. Supports both pipeline input
        (from Get-YtmPlaylist) and direct parameter specification.
        Requires authentication via Connect-YtmAccount first.

    .PARAMETER Song
        A song object from Get-YtmPlaylist containing PlaylistId, SetVideoId, and VideoId.
        Accepts pipeline input.

    .PARAMETER Name
        The name of the playlist to remove from.
        Supports tab completion from your library playlists.

    .PARAMETER Title
        The title of the song to remove.

    .PARAMETER Artist
        Optional artist name to disambiguate when multiple songs have the same title.

    .EXAMPLE
        Get-YtmPlaylist -Name "Chill Vibes" | Where-Object Title -eq "Bad Song" | Remove-YtmPlaylistItem

        Removes "Bad Song" from the "Chill Vibes" playlist using pipeline.

    .EXAMPLE
        Get-YtmPlaylist -Name "Chill Vibes" | Where-Object Artist -match "Nickelback" | Remove-YtmPlaylistItem

        Removes all Nickelback songs from the playlist.

    .EXAMPLE
        Remove-YtmPlaylistItem -Name "Chill Vibes" -Title "Bad Song"

        Removes "Bad Song" from the playlist using direct parameters.

    .EXAMPLE
        Remove-YtmPlaylistItem -Name "Chill Vibes" -Title "Hello" -Artist "Adele"

        Removes "Hello" by Adele, disambiguating from other songs titled "Hello".

    .EXAMPLE
        Get-YtmPlaylist -Name "Chill Vibes" | Where-Object Title -eq "Bad Song" | Remove-YtmPlaylistItem -WhatIf

        Shows what would be removed without actually removing it.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'Direct')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Pipeline', ValueFromPipeline = $true)]
        [PSTypeName('YouTubeMusicPS.Song')]
        [PSCustomObject]$Song,

        [Parameter(Mandatory = $true, ParameterSetName = 'Direct')]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
            try {
                $playlists = Get-YtmPlaylist -ErrorAction SilentlyContinue
                if ($playlists) {
                    $playlists | Where-Object { $_.Name -like "*$wordToComplete*" } | ForEach-Object {
                        $name = $_.Name
                        if ($name -match '\s') {
                            "'$name'"
                        }
                        else {
                            $name
                        }
                    }
                }
            }
            catch { }
        })]
        [string]$Name,

        [Parameter(Mandatory = $true, ParameterSetName = 'Direct')]
        [string]$Title,

        [Parameter(Mandatory = $false, ParameterSetName = 'Direct')]
        [string]$Artist
    )

    begin {
        # Check authentication
        $cookies = Get-YtmStoredCookies
        if (-not $cookies) {
            throw 'Not authenticated. Please run Connect-YtmAccount first.'
        }

        # For pipeline mode, we'll batch removals per playlist
        $removalsByPlaylist = @{}
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Pipeline') {
            # Validate song has required properties
            if (-not $Song.PlaylistId) {
                Write-Error "Song '$($Song.Title)' does not have a PlaylistId. Make sure it came from Get-YtmPlaylist."
                return
            }
            if (-not $Song.SetVideoId) {
                Write-Error "Song '$($Song.Title)' does not have a SetVideoId. This song cannot be removed."
                return
            }
            if (-not $Song.VideoId) {
                Write-Error "Song '$($Song.Title)' does not have a VideoId."
                return
            }

            # Group by playlist for batch processing
            if (-not $removalsByPlaylist.ContainsKey($Song.PlaylistId)) {
                $removalsByPlaylist[$Song.PlaylistId] = @()
            }
            $removalsByPlaylist[$Song.PlaylistId] += $Song
        }
        else {
            # Direct parameter mode - find the song in the playlist
            $playlists = Get-YtmPlaylist -ErrorAction Stop
            $matchingPlaylist = $playlists | Where-Object { $_.Name -eq $Name }

            if (-not $matchingPlaylist) {
                $matchingPlaylist = $playlists | Where-Object { $_.Name -ieq $Name }
            }

            if (-not $matchingPlaylist) {
                throw "Playlist '$Name' not found in your library."
            }

            if ($matchingPlaylist.Count -gt 1) {
                throw "Multiple playlists found matching '$Name'. Please use a more specific name."
            }

            # Get playlist contents
            $playlistSongs = Get-YtmPlaylist -Name $matchingPlaylist.Name -ErrorAction Stop

            # Find matching song(s)
            $matchingSongs = $playlistSongs | Where-Object { $_.Title -eq $Title }

            if (-not $matchingSongs) {
                $matchingSongs = $playlistSongs | Where-Object { $_.Title -ieq $Title }
            }

            if (-not $matchingSongs) {
                throw "Song '$Title' not found in playlist '$Name'."
            }

            # Filter by artist if specified
            if ($Artist) {
                $matchingSongs = $matchingSongs | Where-Object { $_.Artist -match [regex]::Escape($Artist) }
                if (-not $matchingSongs) {
                    throw "Song '$Title' by '$Artist' not found in playlist '$Name'."
                }
            }

            # Handle multiple matches
            if (($matchingSongs | Measure-Object).Count -gt 1 -and -not $Artist) {
                $songList = ($matchingSongs | ForEach-Object { " - $($_.Title) by $($_.Artist)" }) -join "`n"
                throw "Multiple songs found matching '$Title'. Please specify -Artist to disambiguate:`n$songList"
            }

            # Add to removal batch
            foreach ($matchingSong in $matchingSongs) {
                if (-not $removalsByPlaylist.ContainsKey($matchingSong.PlaylistId)) {
                    $removalsByPlaylist[$matchingSong.PlaylistId] = @()
                }
                $removalsByPlaylist[$matchingSong.PlaylistId] += $matchingSong
            }
        }
    }

    end {
        # Process all removals
        foreach ($playlistId in $removalsByPlaylist.Keys) {
            $songsToRemove = $removalsByPlaylist[$playlistId]

            foreach ($songItem in $songsToRemove) {
                $actionDescription = "Remove '$($songItem.Title)' by $($songItem.Artist) from playlist"

                if ($PSCmdlet.ShouldProcess($actionDescription, 'Remove song from playlist')) {
                    Write-Verbose "Removing '$($songItem.Title)' from playlist $playlistId..."

                    $body = @{
                        playlistId = $playlistId
                        actions    = @(
                            @{
                                action         = 'ACTION_REMOVE_VIDEO'
                                setVideoId     = $songItem.SetVideoId
                                removedVideoId = $songItem.VideoId
                            }
                        )
                    }

                    try {
                        $response = Invoke-YtmApi -Endpoint 'browse/edit_playlist' -Body $body
                        Write-Verbose "Successfully removed '$($songItem.Title)'"

                        # Check response for success
                        if ($response.PSObject.Properties['status'] -and $response.status -eq 'STATUS_SUCCEEDED') {
                            Write-Verbose "API confirmed removal succeeded"
                        }
                    }
                    catch {
                        Write-Error "Failed to remove '$($songItem.Title)': $($_.Exception.Message)"
                    }
                }
            }
        }
    }
}