Add playlist functions
This commit is contained in:
@@ -7,9 +7,15 @@ use Data::Dumper;
|
|||||||
|
|
||||||
use YTMusicAPI::YTMusic;
|
use YTMusicAPI::YTMusic;
|
||||||
|
|
||||||
my $yt = YTMusicAPI::YTMusic->new();
|
my $yt = YTMusicAPI::YTMusic->new();
|
||||||
my $search_results = $yt->search('Oasis Wonderwall');
|
|
||||||
|
|
||||||
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;
|
1;
|
||||||
|
|||||||
170
YTMusicAPI/Continuations.pm
Normal file
170
YTMusicAPI/Continuations.pm
Normal file
@@ -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;
|
||||||
@@ -41,8 +41,9 @@ sub initialize_context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub get_visitor_id {
|
sub get_visitor_id {
|
||||||
my ($content) = @_;
|
my ($response) = @_;
|
||||||
my @matches = $content =~ /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g;
|
my @matches =
|
||||||
|
$response->decoded_content =~ /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g;
|
||||||
my $visitor_id = "";
|
my $visitor_id = "";
|
||||||
|
|
||||||
if (@matches) {
|
if (@matches) {
|
||||||
@@ -89,11 +90,15 @@ sub to_int {
|
|||||||
sub sum_total_duration {
|
sub sum_total_duration {
|
||||||
my ($item) = @_;
|
my ($item) = @_;
|
||||||
|
|
||||||
return 0 unless exists $item->{tracks};
|
return 0 unless exists $item->{"tracks"};
|
||||||
|
|
||||||
my $total_duration =
|
my $total_duration = 0;
|
||||||
sum( map { exists $_->{duration_seconds} ? $_->{duration_seconds} : 0 }
|
foreach my $track ( @{ $item->{"tracks"} } ) {
|
||||||
@{ $item->{tracks} } );
|
$total_duration +=
|
||||||
|
exists $track->{"duration_seconds"}
|
||||||
|
? $track->{"duration_seconds"}
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
return $total_duration;
|
return $total_duration;
|
||||||
}
|
}
|
||||||
|
|||||||
330
YTMusicAPI/Mixins/PlaylistMixin.pm
Normal file
330
YTMusicAPI/Mixins/PlaylistMixin.pm
Normal file
@@ -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;
|
||||||
235
YTMusicAPI/Parsers/Playlists.pm
Normal file
235
YTMusicAPI/Parsers/Playlists.pm
Normal file
@@ -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;
|
||||||
@@ -107,11 +107,12 @@ sub parse_duration {
|
|||||||
my ($duration) = @_;
|
my ($duration) = @_;
|
||||||
return $duration unless defined $duration;
|
return $duration unless defined $duration;
|
||||||
|
|
||||||
my @mapped_increments = ( [ 1, 60, 3600 ], reverse split /:/, $duration );
|
my @times = reverse split /:/, $duration;
|
||||||
my $seconds = 0;
|
my @multipliers = ( 1, 60, 3600 );
|
||||||
|
my $seconds = 0;
|
||||||
|
|
||||||
while ( my ( $multiplier, $time ) = splice( @mapped_increments, 0, 2 ) ) {
|
for ( my $i = 0 ; $i < @times ; $i++ ) {
|
||||||
$seconds += $multiplier * $time;
|
$seconds += $multipliers[$i] * $times[$i] if defined $times[$i];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $seconds;
|
return $seconds;
|
||||||
@@ -128,13 +129,13 @@ sub parse_id_name {
|
|||||||
}
|
}
|
||||||
|
|
||||||
our @EXPORT = qw(
|
our @EXPORT = qw(
|
||||||
parse_menu_playlists
|
parse_menu_playlists
|
||||||
get_item_text
|
get_item_text
|
||||||
get_flex_column_item
|
get_flex_column_item
|
||||||
get_fixed_column_item
|
get_fixed_column_item
|
||||||
get_dot_separator_index
|
get_dot_separator_index
|
||||||
parse_duration
|
parse_duration
|
||||||
parse_id_name
|
parse_id_name
|
||||||
);
|
);
|
||||||
|
|
||||||
1;
|
1;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use strict;
|
|||||||
use warnings;
|
use warnings;
|
||||||
|
|
||||||
use Moose;
|
use Moose;
|
||||||
with 'YTMusicAPI::Mixins::SearchMixin';
|
|
||||||
|
with
|
||||||
|
'YTMusicAPI::Mixins::SearchMixin',
|
||||||
|
'YTMusicAPI::Mixins::PlaylistMixin';
|
||||||
|
|
||||||
use JSON;
|
use JSON;
|
||||||
use LWP::UserAgent;
|
use LWP::UserAgent;
|
||||||
@@ -124,18 +127,18 @@ sub _send_request {
|
|||||||
$additionalParams //= "";
|
$additionalParams //= "";
|
||||||
|
|
||||||
@$body{ keys %{ $self->{context} } } = values %{ $self->{context} };
|
@$body{ keys %{ $self->{context} } } = values %{ $self->{context} };
|
||||||
|
my $headers = $self->headers();
|
||||||
|
|
||||||
if ( $self->{_headers} and !exists $self->{_headers}{'X-Goog-Visitor-Id'} )
|
if ( $self->{_headers} and !exists $self->{_headers}{'X-Goog-Visitor-Id'} )
|
||||||
{
|
{
|
||||||
my $visitor_id = get_visitor_id( $self->{_send_get_request} );
|
my $visitor_id = get_visitor_id( $self->_send_get_request(YTM_DOMAIN) );
|
||||||
$self->{_headers}{'X-Goog-Visitor-Id'} = %$visitor_id;
|
$self->{_headers}{'X-Goog-Visitor-Id'} = $visitor_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$self->{_headers}{'Cookie'} = $self->{cookies};
|
$self->{_headers}{'Cookie'} = $self->{cookies};
|
||||||
|
|
||||||
my $request = HTTP::Request->new(
|
my $request = HTTP::Request->new(
|
||||||
POST => YTM_BASE_API . $url . $self->{params} . $additionalParams );
|
POST => YTM_BASE_API . $url . $self->{params} . $additionalParams );
|
||||||
my $headers = $self->headers();
|
|
||||||
|
|
||||||
foreach my $header_name ( keys %$headers ) {
|
foreach my $header_name ( keys %$headers ) {
|
||||||
$request->header( $header_name => $headers->{$header_name} );
|
$request->header( $header_name => $headers->{$header_name} );
|
||||||
@@ -155,11 +158,17 @@ sub _send_request {
|
|||||||
|
|
||||||
sub _send_get_request {
|
sub _send_get_request {
|
||||||
my ( $self, $url, $params ) = @_;
|
my ( $self, $url, $params ) = @_;
|
||||||
|
$params //= "";
|
||||||
|
|
||||||
my $response =
|
my $request = HTTP::Request->new( GET => $url . $params );
|
||||||
$self->{_session}
|
my $headers = $self->{_headers} ? $self->headers() : $self->base_headers();
|
||||||
->get( $url, $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;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user