diff --git a/Tests/Test.pl b/Tests/Test.pl index dc3b6f6..bb6e53d 100644 --- a/Tests/Test.pl +++ b/Tests/Test.pl @@ -7,9 +7,15 @@ use Data::Dumper; use YTMusicAPI::YTMusic; -my $yt = YTMusicAPI::YTMusic->new(); -my $search_results = $yt->search('Oasis Wonderwall'); +my $yt = YTMusicAPI::YTMusic->new(); -print Dumper($search_results); +# my $search_results = $yt->search('Oasis Wonderwall'); +my $search_results = $yt->get_liked_songs(2); + +# print Dumper($search_results); + +foreach my $track ( @{ $search_results->{"tracks"} } ) { + print $track->{"title"} . "\n"; +} 1; diff --git a/YTMusicAPI/Continuations.pm b/YTMusicAPI/Continuations.pm new file mode 100644 index 0000000..d346ae0 --- /dev/null +++ b/YTMusicAPI/Continuations.pm @@ -0,0 +1,170 @@ +package YTMusicAPI::Continuations; + +use strict; +use warnings; + +use Exporter 'import'; + +use YTMusicAPI::Navigation; + +sub get_continuations { + my ( $results, $continuation_type, $limit, $request_func, $parse_func, + $ctoken_path, $reloadable ) + = @_; + $ctoken_path //= ""; + $reloadable //= 0; + + my @items = (); + while ( exists $results->{"continuations"} + && ( !defined $limit || scalar(@items) < $limit ) ) + { + my $additionalParams = + $reloadable + ? get_reloadable_continuation_params($results) + : get_continuation_params( $results, $ctoken_path ); + + my $response = $request_func->($additionalParams); + if ( exists $response->{"continuationContents"} ) { + $results = + $response->{"continuationContents"}->{$continuation_type}; + } + else { + last; + } + + my $contents = get_continuation_contents( $results, $parse_func ); + last if scalar(@$contents) == 0; + push( @items, @$contents ); + } + + return \@items; +} + +sub get_validated_continuations { + my ( $results, $continuation_type, $limit, $per_page, $request_func, + $parse_func, $ctoken_path ) + = @_; + $ctoken_path //= ""; + + my @items = (); + while ( exists $results->{"continuations"} && scalar(@items) < $limit ) { + my $additionalParams = + get_continuation_params( $results, $ctoken_path ); + + my $wrapped_parse_func = sub { + my $raw_response = shift; + return get_parsed_continuation_items( $raw_response, $parse_func, + $continuation_type ); + }; + + my $validate_func = sub { + my $parsed = shift; + return validate_response( $parsed, $per_page, $limit, + scalar(@items) ); + }; + + my $response = + resend_request_until_parsed_response_is_valid( $request_func, + $additionalParams, $wrapped_parse_func, $validate_func, 3 ); + $results = $response->{"results"}; + push( @items, @{ $response->{"parsed"} } ); + } + + return \@items; +} + +sub get_parsed_continuation_items { + my ( $response, $parse_func, $continuation_type ) = @_; + my $results = $response->{"continuationContents"}->{$continuation_type}; + return { + "results" => $results, + "parsed" => get_continuation_contents( $results, $parse_func ) + }; +} + +sub get_continuation_params { + my ( $results, $ctoken_path ) = @_; + $ctoken_path //= ""; + my $ctoken = nav( + $results, + [ + "continuations", 0, + "next" . $ctoken_path . "ContinuationData", "continuation" + ] + ); + return get_continuation_string($ctoken); +} + +sub get_reloadable_continuation_params { + my ($results) = @_; + my $ctoken = nav( $results, + [ "continuations", 0, "reloadContinuationData", "continuation" ] ); + return get_continuation_string($ctoken); +} + +sub get_continuation_string { + my ($ctoken) = @_; + return "&ctoken=" . $ctoken . "&continuation=" . $ctoken; +} + +sub get_continuation_contents { + my ( $continuation, $parse_func ) = @_; + foreach my $term ( "contents", "items" ) { + if ( exists $continuation->{$term} ) { + return $parse_func->( $continuation->{$term} ); + } + } + return []; +} + +sub resend_request_until_parsed_response_is_valid { + my ( $request_func, $request_additional_params, $parse_func, + $validate_func, $max_retries ) + = @_; + + my $response = $request_func->($request_additional_params); + my $parsed_object = $parse_func->($response); + my $retry_counter = 0; + + while ( !$validate_func->($parsed_object) && $retry_counter < $max_retries ) + { + $response = $request_func->($request_additional_params); + my $attempt = $parse_func->($response); + + if ( + scalar( @{ $attempt->{"parsed"} } ) > + scalar( @{ $parsed_object->{"parsed"} } ) ) + { + $parsed_object = $attempt; + } + + $retry_counter++; + } + + return $parsed_object; +} + +sub validate_response { + my ( $response, $per_page, $limit, $current_count ) = @_; + + my $remaining_items_count = $limit - $current_count; + my $expected_items_count = + $per_page < $remaining_items_count ? $per_page : $remaining_items_count; + + # response is invalid, if it has less items than the minimal expected count + return scalar( @{ $response->{"parsed"} } ) >= $expected_items_count; +} + +our @EXPORT = qw( + get_continuations + get_validated_continuations + get_parsed_continuation_items + get_continuation_params + get_reloadable_continuation_params + get_continuation_string + get_continuation_contents + resend_request_until_parsed_response_is_valid + validate_response +); + +1; diff --git a/YTMusicAPI/Helpers.pm b/YTMusicAPI/Helpers.pm index 4b177b8..6cea621 100644 --- a/YTMusicAPI/Helpers.pm +++ b/YTMusicAPI/Helpers.pm @@ -41,8 +41,9 @@ sub initialize_context { } sub get_visitor_id { - my ($content) = @_; - my @matches = $content =~ /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g; + my ($response) = @_; + my @matches = + $response->decoded_content =~ /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g; my $visitor_id = ""; if (@matches) { @@ -89,11 +90,15 @@ sub to_int { sub sum_total_duration { my ($item) = @_; - return 0 unless exists $item->{tracks}; + return 0 unless exists $item->{"tracks"}; - my $total_duration = - sum( map { exists $_->{duration_seconds} ? $_->{duration_seconds} : 0 } - @{ $item->{tracks} } ); + my $total_duration = 0; + foreach my $track ( @{ $item->{"tracks"} } ) { + $total_duration += + exists $track->{"duration_seconds"} + ? $track->{"duration_seconds"} + : 0; + } return $total_duration; } diff --git a/YTMusicAPI/Mixins/PlaylistMixin.pm b/YTMusicAPI/Mixins/PlaylistMixin.pm new file mode 100644 index 0000000..8bea1e8 --- /dev/null +++ b/YTMusicAPI/Mixins/PlaylistMixin.pm @@ -0,0 +1,330 @@ +package YTMusicAPI::Mixins::PlaylistMixin; + +use strict; +use warnings; + +use Moose::Role; + +use YTMusicAPI::Helpers; +use YTMusicAPI::Navigation; +use YTMusicAPI::Parsers::Playlists; +use YTMusicAPI::Continuations; + +sub get_playlist { + my ( $self, $playlistId, $limit, $related, $suggestions_limit ) = @_; + $limit //= 100; + $related //= 0; + $suggestions_limit //= 0; + + my $browseId = $playlistId =~ /^VL/ ? $playlistId : "VL" . $playlistId; + my $body = { "browseId" => $browseId }; + my $endpoint = "browse"; + my $response = $self->_send_request( $endpoint, $body ); + my $results = nav( + $response, + [ + @$SINGLE_COLUMN_TAB, @$SECTION_LIST_ITEM, + "musicPlaylistShelfRenderer" + ] + ); + my %playlist = ( "id" => $results->{"playlistId"} ); + %playlist = ( %playlist, %{ parse_playlist_header($response) } ); + $playlist{"trackCount"} = + defined $playlist{"trackCount"} + ? $playlist{"trackCount"} + : scalar @{ $results->{"contents"} }; + + my $request_func = sub { + my $additionalParams = shift; + return $self->_send_request( $endpoint, $body, $additionalParams ); + }; + + # suggestions and related are missing e.g. on liked songs + my $section_list = + nav( $response, [ @$SINGLE_COLUMN_TAB, "sectionListRenderer" ] ); + $playlist{"related"} = []; + my $parse_func; + my $continuation; + if ( exists $section_list->{"continuations"} ) { + my $additionalParams = get_continuation_params($section_list); + my $own_playlist = exists $response->{"header"} + ->{"musicEditablePlaylistDetailHeaderRenderer"}; + if ( $own_playlist && ( $suggestions_limit > 0 || $related ) ) { + $parse_func = sub { + my $results = shift; + return parse_playlist_items($results); + }; + my $suggested = $request_func->($additionalParams); + $continuation = nav( $suggested, $SECTION_LIST_CONTINUATION ); + $additionalParams = get_continuation_params($continuation); + my $suggestions_shelf = + nav( $continuation, [ $CONTENT, $MUSIC_SHELF ] ); + $playlist{"suggestions"} = + get_continuation_contents( $suggestions_shelf, $parse_func ); + + $playlist{"suggestions"} = [ + @{ $playlist{"suggestions"} }, + @{ + get_continuations( + $suggestions_shelf, + "musicShelfContinuation", + $suggestions_limit - + scalar @{ $playlist{"suggestions"} }, + $request_func, + $parse_func, + 1 + ) + } + ]; + } + + if ($related) { + $response = $request_func->($additionalParams); + $continuation = nav( $response, $SECTION_LIST_CONTINUATION, 1 ); + if ($continuation) { + $parse_func = sub { + my $results = shift; + return parse_content_list( $results, \&parse_playlist ); + }; + $playlist{"related"} = get_continuation_contents( + nav( $continuation, [ $CONTENT, $CAROUSEL ] ), + $parse_func ); + } + } + } + + $playlist{"tracks"} = []; + if ( exists $results->{"contents"} ) { + $playlist{"tracks"} = parse_playlist_items( $results->{"contents"} ); + + $parse_func = sub { + my $contents = shift; + return parse_playlist_items($contents); + }; + if ( exists $results->{"continuations"} ) { + $playlist{"tracks"} = [ + @{ $playlist{"tracks"} }, + @{ + get_continuations( + $results, "musicPlaylistShelfContinuation", + $limit, $request_func, + $parse_func + ) + } + ]; + } + } + + $playlist{"duration_seconds"} = sum_total_duration( \%playlist ); + return \%playlist; +} + +sub get_liked_songs { + my ( $self, $limit ) = @_; + + $limit //= 100; + return $self->get_playlist( "LM", $limit ); +} + +sub get_saved_episodes { + my ( $self, $limit ) = @_; + + $limit //= 100; + return $self->get_playlist( "SE", $limit ); +} + +sub create_playlist { + my ( $self, $title, $description, $privacy_status, $video_ids, + $source_playlist ) + = @_; + $privacy_status //= 'PRIVATE'; + + $self->_check_auth(); + + my $body = { + "title" => $title, + "description" => html_to_txt($description) + , # YT does not allow HTML tags + "privacyStatus" => $privacy_status, + }; + + $body->{"videoIds"} = $video_ids if defined $video_ids; + $body->{"sourcePlaylistId"} = $source_playlist if defined $source_playlist; + + my $endpoint = "playlist/create"; + my $response = $self->_send_request( $endpoint, $body ); + + return + exists $response->{"playlistId"} ? $response->{"playlistId"} : $response; +} + +sub edit_playlist { + my ( $self, $playlistId, $title, $description, $privacyStatus, $moveItem, + $addPlaylistId, $addToTop ) + = @_; + + $self->_check_auth(); + + my $body = { "playlistId" => validate_playlist_id($playlistId) }; + my @actions = (); + + push( @actions, + { "action" => "ACTION_SET_PLAYLIST_NAME", "playlistName" => $title } ) + if defined $title; + + push( + @actions, + { + "action" => "ACTION_SET_PLAYLIST_DESCRIPTION", + "playlistDescription" => $description + } + ) if defined $description; + + push( + @actions, + { + "action" => "ACTION_SET_PLAYLIST_PRIVACY", + "playlistPrivacy" => $privacyStatus + } + ) if defined $privacyStatus; + + if ( defined $moveItem ) { + push( + @actions, + { + "action" => "ACTION_MOVE_VIDEO_BEFORE", + "setVideoId" => $moveItem->[0], + "movedSetVideoIdSuccessor" => $moveItem->[1], + } + ); + } + + push( + @actions, + { + "action" => "ACTION_ADD_PLAYLIST", + "addedFullListId" => $addPlaylistId + } + ) if defined $addPlaylistId; + + if ( defined $addToTop ) { + my $addToTopValue = $addToTop ? "true" : "false"; + push( + @actions, + { + "action" => "ACTION_SET_ADD_TO_TOP", + "addToTop" => $addToTopValue + } + ); + } + + $body->{"actions"} = \@actions; + my $endpoint = "browse/edit_playlist"; + my $response = $self->_send_request( $endpoint, $body ); + + return exists $response->{"status"} ? $response->{"status"} : $response; +} + +sub delete_playlist { + my ( $self, $playlistId ) = @_; + $self->_check_auth(); + my $body = { "playlistId" => validate_playlist_id($playlistId) }; + my $endpoint = "playlist/delete"; + my $response = $self->_send_request( $endpoint, $body ); + return exists $response->{"status"} ? $response->{"status"} : $response; +} + +sub add_playlist_items { + my ( $self, $playlistId, $videoIds, $source_playlist, $duplicates ) = @_; + $duplicates //= 0; + + $self->_check_auth(); + + my $body = { + "playlistId" => validate_playlist_id($playlistId), + "actions" => [] + }; + + if ( !$videoIds && !$source_playlist ) { + die "You must provide either videoIds or " + . "a source_playlist to add to the playlist"; + } + + if ($videoIds) { + foreach my $videoId (@$videoIds) { + my $action = + { "action" => "ACTION_ADD_VIDEO", "addedVideoId" => $videoId }; + $action->{"dedupeOption"} = "DEDUPE_OPTION_SKIP" if $duplicates; + push( @{ $body->{"actions"} }, $action ); + } + } + + if ($source_playlist) { + push( + @{ $body->{"actions"} }, + { + "action" => "ACTION_ADD_PLAYLIST", + "addedFullListId" => $source_playlist + } + ); + + # add an empty ACTION_ADD_VIDEO because otherwise + # YTM doesn't return the dict that maps videoIds to their new setVideoIds + if ( !$videoIds ) { + push( + @{ $body->{"actions"} }, + { "action" => "ACTION_ADD_VIDEO", "addedVideoId" => undef } + ); + } + } + + my $endpoint = "browse/edit_playlist"; + my $response = $self->_send_request( $endpoint, $body ); + if ( exists $response->{"status"} && $response->{"status"} =~ /SUCCEEDED/ ) + { + my @result_dict = map { $_->get("playlistEditVideoAddedResultData") } + @{ $response->{"playlistEditResults"} // [] }; + return { + "status" => $response->{"status"}, + "playlistEditResults" => \@result_dict + }; + } + else { + return $response; + } +} + +sub remove_playlist_items { + my ( $self, $playlistId, $videos ) = @_; + + $self->_check_auth(); + + @$videos = + grep { exists $_->{"videoId"} && exists $_->{"setVideoId"} } @$videos; + + die "Cannot remove songs, because setVideoId is missing. " + . "Do you own this playlist?" + if scalar(@$videos) == 0; + + my $body = { + "playlistId" => validate_playlist_id($playlistId), + "actions" => [] + }; + + foreach my $video (@$videos) { + push( + @{ $body->{"actions"} }, + { + "setVideoId" => $video->{"setVideoId"}, + "removedVideoId" => $video->{"videoId"}, + "action" => "ACTION_REMOVE_VIDEO", + } + ); + } + + my $endpoint = "browse/edit_playlist"; + my $response = $self->_send_request( $endpoint, $body ); + return exists $response->{"status"} ? $response->{"status"} : $response; +} + +1; diff --git a/YTMusicAPI/Parsers/Playlists.pm b/YTMusicAPI/Parsers/Playlists.pm new file mode 100644 index 0000000..42b2dbe --- /dev/null +++ b/YTMusicAPI/Parsers/Playlists.pm @@ -0,0 +1,235 @@ +package YTMusicAPI::Parsers::Playlists; + +use strict; +use warnings; + +use Exporter 'import'; + +use YTMusicAPI::Parsers::Utils; +use YTMusicAPI::Parsers::Songs; +use YTMusicAPI::Navigation; + +sub parse_playlist_header { + my ($response) = @_; + + my $playlist = {}; + my $header; + my $own_playlist = + exists $response->{"header"}{"musicEditablePlaylistDetailHeaderRenderer"}; + + if ( !$own_playlist ) { + $header = $response->{"header"}{"musicDetailHeaderRenderer"}; + $playlist->{"privacy"} = "PUBLIC"; + } + else { + $header = + $response->{"header"}{"musicEditablePlaylistDetailHeaderRenderer"}; + $playlist->{"privacy"} = + $header->{"editHeader"}{"musicPlaylistEditHeaderRenderer"}{"privacy"}; + $header = $header->{"header"}{"musicDetailHeaderRenderer"}; + } + + $playlist->{"owned"} = $own_playlist; + $playlist->{"title"} = nav( $header, $TITLE_TEXT ); + $playlist->{"thumbnails"} = nav( $header, $THUMBNAIL_CROPPED ); + $playlist->{"description"} = nav( $header, $DESCRIPTION, 1 ); + my $run_count = scalar( @{ nav( $header, $SUBTITLE_RUNS ) } ); + + if ( $run_count > 1 ) { + $playlist->{"author"} = { + "name" => nav( $header, $SUBTITLE2 ), + "id" => + nav( $header, [ $SUBTITLE_RUNS, 2, $NAVIGATION_BROWSE_ID ], 1 ), + }; + if ( $run_count == 5 ) { + $playlist->{"year"} = nav( $header, $SUBTITLE3 ); + } + + } + + $playlist->{"views"} = undef; + $playlist->{"duration"} = undef; + $playlist->{"trackCount"} = undef; + + if ( exists $header->{"secondSubtitle"}{"runs"} ) { + my $second_subtitle_runs = $header->{"secondSubtitle"}{"runs"}; + my $has_views = ( $#$second_subtitle_runs > 3 ) * 2; + $playlist->{"views"} = + !$has_views ? undef : scalar $second_subtitle_runs->[0]->{"text"}; + my $has_duration = ( $#$second_subtitle_runs > 1 ) * 2; + $playlist->{"duration"} = ( + !$has_duration + ? undef + : $second_subtitle_runs->[ $has_views + $has_duration ]->{"text"} + ); + my @song_count = + split( " ", $second_subtitle_runs->[ $has_views + 0 ]->{"text"} ); + @song_count = $#song_count > 1 ? ( scalar $song_count[0] ) : 0; + $playlist->{"trackCount"} = @song_count; + } + + return $playlist; +} + +sub parse_playlist_items { + my ( $results, $menu_entries, $is_album ) = @_; + $menu_entries //= []; + $is_album //= 0; + + my $songs = []; + foreach my $result (@$results) { + next unless exists $result->{$MRLIR}; + my $data = $result->{$MRLIR}; + my $song = parse_playlist_item( $data, $menu_entries, $is_album ); + push @$songs, $song if $song; + } + + return $songs; +} + +sub parse_playlist_item { + my ( $data, $menu_entries, $is_album ) = @_; + $menu_entries //= []; + $is_album //= 0; + + my ( $videoId, $setVideoId ) = ( undef, undef ); + my ( $like, $feedback_tokens, $library_status ) = ( undef, undef, undef ); + + # if the item has a menu, find its setVideoId + if ( exists $data->{"menu"} ) { + foreach my $item ( @{ nav( $data, $MENU_ITEMS ) } ) { + if ( exists $item->{"menuServiceItemRenderer"} ) { + my $menu_service = nav( $item, $MENU_SERVICE ); + if ( exists $menu_service->{"playlistEditEndpoint"} ) { + $setVideoId = nav( + $menu_service, + [ "playlistEditEndpoint", "actions", 0, "setVideoId" ], + 1 + ); + $videoId = nav( + $menu_service, + [ + "playlistEditEndpoint", "actions", + 0, "removedVideoId" + ], + 1 + ); + } + } + + if ( exists $item->{$TOGGLE_MENU} ) { + $feedback_tokens = parse_song_menu_tokens($item); + $library_status = parse_song_library_status($item); + } + } + } + + # if item is not playable, the videoId was retrieved above + if ( defined nav( $data, $PLAY_BUTTON, 1 ) ) { + if ( exists nav( $data, $PLAY_BUTTON )->{"playNavigationEndpoint"} ) { + $videoId = nav( $data, $PLAY_BUTTON )->{"playNavigationEndpoint"} + ->{"watchEndpoint"}->{"videoId"}; + + if ( exists $data->{"menu"} ) { + $like = nav( $data, $MENU_LIKE_STATUS, 1 ); + } + } + } + + my $title = get_item_text( $data, 0 ); + return undef if $title eq "Song deleted"; + + my $flex_column_count = scalar @{ $data->{"flexColumns"} }; + + my $artists = parse_song_artists( $data, 1 ); + + my $album = + !$is_album ? parse_song_album( $data, $flex_column_count - 1 ) : undef; + + my $views = + $flex_column_count == 4 || $is_album ? get_item_text( $data, 2 ) : undef; + + my $duration; + if ( exists $data->{"fixedColumns"} ) { + if ( + exists get_fixed_column_item( $data, 0 )->{"text"}->{"simpleText"} ) + { + $duration = + get_fixed_column_item( $data, 0 )->{"text"}->{"simpleText"}; + } + else { + $duration = get_fixed_column_item( $data, 0 )->{"text"}->{"runs"}[0] + ->{"text"}; + } + } + + my $thumbnails = nav( $data, $THUMBNAILS, 1 ); + + my $isAvailable = + exists $data->{"musicItemRendererDisplayPolicy"} + ? $data->{"musicItemRendererDisplayPolicy"} ne + "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT" + : 1; + + my $isExplicit = defined nav( $data, $BADGE_LABEL, 1 ); + + my $videoType = nav( + $data, + [ + $MENU_ITEMS, 0, $MNIR, "navigationEndpoint", + @$NAVIGATION_VIDEO_TYPE + ], + 1, + ); + + my $song = { + "videoId" => $videoId, + "title" => $title, + "artists" => $artists, + "album" => $album, + "likeStatus" => $like, + "inLibrary" => $library_status, + "thumbnails" => $thumbnails, + "isAvailable" => $isAvailable, + "isExplicit" => $isExplicit, + "videoType" => $videoType, + "views" => $views, + }; + + if ($is_album) { + $song->{"trackNumber"} = + $isAvailable + ? int( nav( $data, [ "index", "runs", 0, "text" ] ) ) + : undef; + } + + if ( defined $duration ) { + $song->{"duration"} = $duration; + $song->{"duration_seconds"} = parse_duration($duration); + } + $song->{"setVideoId"} = $setVideoId if defined $setVideoId; + $song->{"feedbackTokens"} = $feedback_tokens if defined $feedback_tokens; + + if ($menu_entries) { + foreach my $menu_entry (@$menu_entries) { + $song->{ $menu_entry->[-1] } = + nav( $data, [ @{$MENU_ITEMS}, @$menu_entry ] ); + } + } + + return $song; +} + +sub validate_playlist_id { + my ($playlistId) = @_; + return $playlistId =~ /^VL/ ? substr( $playlistId, 2 ) : $playlistId; +} + +our @EXPORT = qw( + parse_playlist_header + parse_playlist_items + parse_playlist_item + validate_playlist_id +); + +1; diff --git a/YTMusicAPI/Parsers/Utils.pm b/YTMusicAPI/Parsers/Utils.pm index b18d72f..7c45eb7 100644 --- a/YTMusicAPI/Parsers/Utils.pm +++ b/YTMusicAPI/Parsers/Utils.pm @@ -107,11 +107,12 @@ sub parse_duration { my ($duration) = @_; return $duration unless defined $duration; - my @mapped_increments = ( [ 1, 60, 3600 ], reverse split /:/, $duration ); - my $seconds = 0; + my @times = reverse split /:/, $duration; + my @multipliers = ( 1, 60, 3600 ); + my $seconds = 0; - while ( my ( $multiplier, $time ) = splice( @mapped_increments, 0, 2 ) ) { - $seconds += $multiplier * $time; + for ( my $i = 0 ; $i < @times ; $i++ ) { + $seconds += $multipliers[$i] * $times[$i] if defined $times[$i]; } return $seconds; @@ -128,13 +129,13 @@ sub parse_id_name { } our @EXPORT = qw( - parse_menu_playlists - get_item_text - get_flex_column_item - get_fixed_column_item - get_dot_separator_index - parse_duration - parse_id_name + parse_menu_playlists + get_item_text + get_flex_column_item + get_fixed_column_item + get_dot_separator_index + parse_duration + parse_id_name ); 1; diff --git a/YTMusicAPI/YTMusic.pm b/YTMusicAPI/YTMusic.pm index 621aae0..3e7a4ec 100644 --- a/YTMusicAPI/YTMusic.pm +++ b/YTMusicAPI/YTMusic.pm @@ -4,7 +4,10 @@ use strict; use warnings; use Moose; -with 'YTMusicAPI::Mixins::SearchMixin'; + +with + 'YTMusicAPI::Mixins::SearchMixin', + 'YTMusicAPI::Mixins::PlaylistMixin'; use JSON; use LWP::UserAgent; @@ -124,18 +127,18 @@ sub _send_request { $additionalParams //= ""; @$body{ keys %{ $self->{context} } } = values %{ $self->{context} }; + my $headers = $self->headers(); if ( $self->{_headers} and !exists $self->{_headers}{'X-Goog-Visitor-Id'} ) { - my $visitor_id = get_visitor_id( $self->{_send_get_request} ); - $self->{_headers}{'X-Goog-Visitor-Id'} = %$visitor_id; + my $visitor_id = get_visitor_id( $self->_send_get_request(YTM_DOMAIN) ); + $self->{_headers}{'X-Goog-Visitor-Id'} = $visitor_id; } $self->{_headers}{'Cookie'} = $self->{cookies}; my $request = HTTP::Request->new( POST => YTM_BASE_API . $url . $self->{params} . $additionalParams ); - my $headers = $self->headers(); foreach my $header_name ( keys %$headers ) { $request->header( $header_name => $headers->{$header_name} ); @@ -155,11 +158,17 @@ sub _send_request { sub _send_get_request { my ( $self, $url, $params ) = @_; + $params //= ""; - my $response = - $self->{_session} - ->get( $url, $self->{_headers} ? $self->headers() : $self->base_headers(), - ); + my $request = HTTP::Request->new( GET => $url . $params ); + my $headers = $self->{_headers} ? $self->headers() : $self->base_headers(); + $self->{_headers}{'Cookie'} = $self->{cookies}; + + foreach my $header_name ( keys %$headers ) { + $request->header( $header_name => $headers->{$header_name} ); + } + + my $response = $self->{_session}->request($request); return $response; }