Public/Get-YtmPlaylist.ps1
|
function Get-YtmPlaylist { <# .SYNOPSIS Retrieves playlists or playlist contents from YouTube Music. .DESCRIPTION When called without parameters, lists all playlists in your library. When called with -Name or -Id, retrieves the songs in that playlist. Requires authentication via Connect-YtmAccount first. .PARAMETER Name The name of the playlist to retrieve contents for. Supports tab completion from your library playlists. .PARAMETER Id The playlist ID to retrieve contents for. Use this for public/community playlists not in your library. .PARAMETER Limit Maximum number of items to retrieve. Default is 0 which retrieves all items. .EXAMPLE Get-YtmPlaylist Lists all playlists in your library. .EXAMPLE Get-YtmPlaylist -Name "Chill Vibes" Gets all songs in the "Chill Vibes" playlist. .EXAMPLE Get-YtmPlaylist -Name "Chill Vibes" -Limit 50 Gets up to 50 songs from the playlist. .EXAMPLE Get-YtmPlaylist -Name "Chill Vibes" | Where-Object Artist -match "Adele" Gets songs by Adele from the playlist. .EXAMPLE Get-YtmPlaylist -Id "PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf" Gets songs from a playlist by its ID. .OUTPUTS YouTubeMusicPS.Playlist (when listing playlists) YouTubeMusicPS.Song (when getting playlist contents) #> [CmdletBinding(DefaultParameterSetName = 'List')] param ( [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0)] [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) # Get playlists for tab completion try { $playlists = Get-YtmPlaylist -ErrorAction SilentlyContinue if ($playlists) { $playlists | Where-Object { $_.Name -like "*$wordToComplete*" } | ForEach-Object { $name = $_.Name # Quote names with spaces if ($name -match '\s') { "'$name'" } else { $name } } } } catch { # Silently fail if not authenticated } })] [string]$Name, [Parameter(Mandatory = $true, ParameterSetName = 'ById')] [string]$Id, [Parameter(Mandatory = $false)] [ValidateRange(0, [int]::MaxValue)] [int]$Limit = 0 ) # Check authentication $cookies = Get-YtmStoredCookies if (-not $cookies) { throw 'Not authenticated. Please run Connect-YtmAccount first.' } # Determine which mode we're in if ($PSCmdlet.ParameterSetName -eq 'List') { # List all playlists Get-LibraryPlaylists } elseif ($PSCmdlet.ParameterSetName -eq 'ByName') { # Resolve name to ID and get contents $playlists = Get-LibraryPlaylists $matchingPlaylist = $playlists | Where-Object { $_.Name -eq $Name } if (-not $matchingPlaylist) { # Try case-insensitive match $matchingPlaylist = $playlists | Where-Object { $_.Name -ieq $Name } } if (-not $matchingPlaylist) { throw "Playlist '$Name' not found in your library. Use Get-YtmPlaylist to see available playlists." } if ($matchingPlaylist.Count -gt 1) { throw "Multiple playlists found matching '$Name'. Please use a more specific name." } Get-PlaylistContents -PlaylistId $matchingPlaylist.PlaylistId -Limit $Limit } else { # Get contents by ID Get-PlaylistContents -PlaylistId $Id -Limit $Limit } } function Get-LibraryPlaylists { <# .SYNOPSIS Internal helper to retrieve library playlists. #> Write-Verbose "Fetching playlists from YouTube Music library..." $body = @{ browseId = 'FEmusic_liked_playlists' } try { $response = Invoke-YtmApi -Endpoint 'browse' -Body $body } catch { throw "Failed to retrieve playlists: $($_.Exception.Message)" } # Navigate to the grid/shelf containing playlists $items = $null if ($response.PSObject.Properties['contents']) { $tabs = $response.contents.singleColumnBrowseResultsRenderer.tabs if ($tabs) { foreach ($tab in $tabs) { $tabRenderer = $tab.tabRenderer if ($tabRenderer.PSObject.Properties['content']) { $sectionList = $tabRenderer.content.sectionListRenderer if ($sectionList.PSObject.Properties['contents']) { foreach ($section in $sectionList.contents) { # Look for gridRenderer or musicShelfRenderer if ($section.PSObject.Properties['gridRenderer']) { $items = $section.gridRenderer.items break } elseif ($section.PSObject.Properties['musicShelfRenderer']) { $items = $section.musicShelfRenderer.contents break } elseif ($section.PSObject.Properties['itemSectionRenderer']) { $itemSection = $section.itemSectionRenderer if ($itemSection.PSObject.Properties['contents']) { foreach ($item in $itemSection.contents) { if ($item.PSObject.Properties['gridRenderer']) { $items = $item.gridRenderer.items break } } } } } } } } } } if (-not $items) { Write-Warning "No playlists found or unable to parse response." return } # Convert each playlist item foreach ($item in $items) { # Skip "New Playlist" button or similar non-playlist items if ($item.PSObject.Properties['musicTwoRowItemRenderer']) { $playlist = ConvertTo-YtmPlaylist -InputObject $item if ($playlist.PlaylistId -and $playlist.Name) { $playlist } } } } function Get-PlaylistContents { <# .SYNOPSIS Internal helper to retrieve playlist contents. #> param ( [Parameter(Mandatory = $true)] [string]$PlaylistId, [Parameter(Mandatory = $false)] [int]$Limit = 0 ) Write-Verbose "Fetching playlist contents for $PlaylistId..." # Validate playlist ID if ([string]::IsNullOrWhiteSpace($PlaylistId)) { throw "PlaylistId cannot be null or empty." } # YouTube playlist IDs should only contain alphanumeric, underscore, dash if ($PlaylistId -notmatch '^(VL)?[A-Za-z0-9_\-]+$') { throw "Invalid playlist ID format: $PlaylistId" } # Playlist browse IDs need 'VL' prefix $browseId = if ($PlaylistId.StartsWith('VL')) { $PlaylistId } else { "VL$PlaylistId" } # Store the actual playlist ID (without VL prefix) for song objects $actualPlaylistId = if ($PlaylistId.StartsWith('VL')) { $PlaylistId.Substring(2) } else { $PlaylistId } $body = @{ browseId = $browseId } try { $response = Invoke-YtmApi -Endpoint 'browse' -Body $body } catch { throw "Failed to retrieve playlist contents: $($_.Exception.Message)" } # Find the music shelf in the response $musicShelf = $null if ($response.PSObject.Properties['contents']) { $tabs = $response.contents.singleColumnBrowseResultsRenderer.tabs if ($tabs) { foreach ($tab in $tabs) { $tabRenderer = $tab.tabRenderer if ($tabRenderer.PSObject.Properties['content']) { $sectionList = $tabRenderer.content.sectionListRenderer if ($sectionList.PSObject.Properties['contents']) { foreach ($section in $sectionList.contents) { if ($section.PSObject.Properties['musicPlaylistShelfRenderer']) { $musicShelf = $section.musicPlaylistShelfRenderer break } elseif ($section.PSObject.Properties['musicShelfRenderer']) { $musicShelf = $section.musicShelfRenderer break } } } } } } } if (-not $musicShelf -or -not $musicShelf.PSObject.Properties['contents']) { Write-Warning "Playlist is empty or unable to parse response." return } $totalCount = 0 $shouldContinue = $true # Process initial batch $contents = $musicShelf.contents foreach ($item in $contents) { if ($Limit -gt 0 -and $totalCount -ge $Limit) { $shouldContinue = $false break } $song = ConvertTo-YtmSong -InputObject $item -PlaylistId $actualPlaylistId if ($song.VideoId -and $song.Title) { $song $totalCount++ } } # Get continuation token for pagination $continuationToken = $null if ($musicShelf.PSObject.Properties['continuations']) { $continuations = $musicShelf.continuations if ($continuations -and $continuations.Count -gt 0) { $continuationItem = $continuations[0] if ($continuationItem.PSObject.Properties['nextContinuationData']) { $continuationToken = $continuationItem.nextContinuationData.continuation } } } # Continue fetching while we have a token and haven't hit the limit $pageNumber = 1 while ($shouldContinue -and $continuationToken) { $pageNumber++ Write-Verbose "Fetching more songs (retrieved $totalCount so far)..." $progressParams = @{ Activity = 'Retrieving playlist songs' Status = "Retrieved $totalCount songs (page $pageNumber)" } if ($Limit -gt 0) { $progressParams['PercentComplete'] = [math]::Min(100, [int]($totalCount / $Limit * 100)) } Write-Progress @progressParams try { $response = Invoke-YtmApi -Endpoint 'browse' -Body $body -ContinuationToken $continuationToken } catch { Write-Progress -Activity 'Retrieving playlist songs' -Completed Write-Warning "Failed to fetch continuation: $($_.Exception.Message)" break } # Continuation responses have a different structure $contents = $null $newContinuationToken = $null if ($response.PSObject.Properties['continuationContents']) { $shelfContinuation = $response.continuationContents.musicPlaylistShelfContinuation if (-not $shelfContinuation) { $shelfContinuation = $response.continuationContents.musicShelfContinuation } if ($shelfContinuation) { $contents = $shelfContinuation.contents if ($shelfContinuation.PSObject.Properties['continuations']) { $continuationItem = $shelfContinuation.continuations[0] if ($continuationItem.PSObject.Properties['nextContinuationData']) { $newContinuationToken = $continuationItem.nextContinuationData.continuation } } } } if (-not $contents -or $contents.Count -eq 0) { Write-Verbose "No more songs in continuation response" break } # Output songs from continuation foreach ($item in $contents) { if ($Limit -gt 0 -and $totalCount -ge $Limit) { $shouldContinue = $false break } $song = ConvertTo-YtmSong -InputObject $item -PlaylistId $actualPlaylistId if ($song.VideoId -and $song.Title) { $song $totalCount++ } } $continuationToken = $newContinuationToken } # Clear progress bar if ($pageNumber -gt 1) { Write-Progress -Activity 'Retrieving playlist songs' -Completed } Write-Verbose "Retrieved $totalCount songs from playlist" } |