Add playlist functions

This commit is contained in:
mschuepbach
2024-03-24 23:34:26 +01:00
parent fe4a7dc49e
commit e3a184aa63
7 changed files with 784 additions and 28 deletions

View File

@@ -8,8 +8,14 @@ use Data::Dumper;
use YTMusicAPI::YTMusic;
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;

170
YTMusicAPI/Continuations.pm Normal file
View 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;

View File

@@ -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;
}

View 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;

View 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;

View File

@@ -107,11 +107,12 @@ sub parse_duration {
my ($duration) = @_;
return $duration unless defined $duration;
my @mapped_increments = ( [ 1, 60, 3600 ], reverse split /:/, $duration );
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;

View File

@@ -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;
}