From fe4a7dc49e117793bae3830e7c2a89e3f06e5a28 Mon Sep 17 00:00:00 2001 From: mschuepbach Date: Sun, 24 Mar 2024 18:17:49 +0100 Subject: [PATCH] Initial commit --- .gitignore | 50 ++++ README.md | 13 + Tests/Mixins/TestPlaylists.pm | 66 +++++ Tests/Test.pl | 15 ++ YTMusicAPI.pm | 8 + YTMusicAPI/Auth/AuthTypes.pm | 17 ++ YTMusicAPI/Constants.pm | 57 +++++ YTMusicAPI/Helpers.pm | 128 ++++++++++ YTMusicAPI/Mixins/SearchMixin.pm | 177 ++++++++++++++ YTMusicAPI/Navigation.pm | 253 ++++++++++++++++++++ YTMusicAPI/Parsers/Albums.pm | 56 +++++ YTMusicAPI/Parsers/Parser.pm | 78 ++++++ YTMusicAPI/Parsers/Search.pm | 397 +++++++++++++++++++++++++++++++ YTMusicAPI/Parsers/Songs.pm | 155 ++++++++++++ YTMusicAPI/Parsers/Utils.pm | 140 +++++++++++ YTMusicAPI/YTMusic.pm | 174 ++++++++++++++ 16 files changed, 1784 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Tests/Mixins/TestPlaylists.pm create mode 100644 Tests/Test.pl create mode 100644 YTMusicAPI.pm create mode 100644 YTMusicAPI/Auth/AuthTypes.pm create mode 100644 YTMusicAPI/Constants.pm create mode 100644 YTMusicAPI/Helpers.pm create mode 100644 YTMusicAPI/Mixins/SearchMixin.pm create mode 100644 YTMusicAPI/Navigation.pm create mode 100644 YTMusicAPI/Parsers/Albums.pm create mode 100644 YTMusicAPI/Parsers/Parser.pm create mode 100644 YTMusicAPI/Parsers/Search.pm create mode 100644 YTMusicAPI/Parsers/Songs.pm create mode 100644 YTMusicAPI/Parsers/Utils.pm create mode 100644 YTMusicAPI/YTMusic.pm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d2d7e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +!Build/ +.last_cover_stats +/META.yml +/META.json +/MYMETA.* +*.o +*.pm.tdy +*.bs + +# Devel::Cover +cover_db/ + +# Devel::NYTProf +nytprof.out + +# Dist::Zilla +/.build/ + +# Module::Build +_build/ +Build +Build.bat + +# Module::Install +inc/ + +# ExtUtils::MakeMaker +/blib/ +/_eumm/ +/*.gz +/Makefile +/Makefile.old +/MANIFEST.bak +/pm_to_blib +/*.zip + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +**.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c80ae95 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ + +## Usage + +```perl +use strict; + +use YTMusicAPI qw(YTMusic); + +my $yt = YTMusic->new('oauth.json'); +my $playlistId = $yt->create_playlist('test', 'test description'); +my $search_results = $yt->search('Oasis Wonderwall'); +$yt->add_playlist_items($playlistId, $search_results->[0]->{videoId}); +``` \ No newline at end of file diff --git a/Tests/Mixins/TestPlaylists.pm b/Tests/Mixins/TestPlaylists.pm new file mode 100644 index 0000000..a0f862b --- /dev/null +++ b/Tests/Mixins/TestPlaylists.pm @@ -0,0 +1,66 @@ +use strict; +use warnings; + +use Test::More tests => 2; +use Test::Exception; + +sub test_get_playlist_foreign { + my ( $yt, $yt_auth, $yt_oauth ) = @_; + + lives_ok { + eval { $yt->get_playlist("PLABC") }; + if ($@) { + die "Expected exception not thrown"; + } + } + 'Playlist retrieval with invalid ID should fail'; + + my $playlist = $yt_auth->get_playlist( + "PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", + { limit => 300, suggestions_limit => 7 } + ); + ok( + length( $playlist->{"duration"} ) > 5, + "Playlist duration length check" + ); + ok( scalar @{ $playlist->{"tracks"} } > 200, "Tracks count check" ); + ok( + !exists $playlist->{"suggestions"}, + "Playlist should not contain suggestions" + ); + ok( !$playlist->{"owned"}, "Playlist should not be owned" ); + + $yt->get_playlist("RDATgXd-"); + ok( + scalar @{ $playlist->{"tracks"} } >= 100, + "Tracks count check for RDATgXd- playlist" + ); + + $playlist = $yt_oauth->get_playlist( + "PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", + { limit => undef, related => 1 } + ); + ok( + scalar @{ $playlist->{"tracks"} } > 200, + "Tracks count check for oauth playlist" + ); + ok( + scalar @{ $playlist->{"related"} } == 0, + "Related count check for oauth playlist" + ); +} + +sub test_get_playlist_owned { + my ( $config, $yt_brand ) = @_; + + my $playlist = $yt_brand->get_playlist( $config->{"playlists"}->{"own"}, + { related => 1, suggestions_limit => 21 } ); + ok( + scalar @{ $playlist->{"tracks"} } < 100, + "Owned playlist tracks count check" + ); + ok( scalar @{ $playlist->{"suggestions"} } == 21, + "Suggestions count check" ); + ok( scalar @{ $playlist->{"related"} } == 10, "Related count check" ); + ok( $playlist->{"owned"}, "Playlist ownership check" ); +} diff --git a/Tests/Test.pl b/Tests/Test.pl new file mode 100644 index 0000000..dc3b6f6 --- /dev/null +++ b/Tests/Test.pl @@ -0,0 +1,15 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Data::Dumper; + +use YTMusicAPI::YTMusic; + +my $yt = YTMusicAPI::YTMusic->new(); +my $search_results = $yt->search('Oasis Wonderwall'); + +print Dumper($search_results); + +1; diff --git a/YTMusicAPI.pm b/YTMusicAPI.pm new file mode 100644 index 0000000..aca5364 --- /dev/null +++ b/YTMusicAPI.pm @@ -0,0 +1,8 @@ +package YTMusicAPI; + +use strict; +use warnings; + +use YTMusicAPI::YTMusic; + +1; diff --git a/YTMusicAPI/Auth/AuthTypes.pm b/YTMusicAPI/Auth/AuthTypes.pm new file mode 100644 index 0000000..7455f25 --- /dev/null +++ b/YTMusicAPI/Auth/AuthTypes.pm @@ -0,0 +1,17 @@ +package AuthType; + +use constant { + UNAUTHORIZED => 1, + BROWSER => 2, + OAUTH_DEFAULT => 3, # client auth via OAuth token refreshing + OAUTH_CUSTOM_CLIENT => + 4, # YTM instance is using a non-default OAuth client (id & secret) + OAUTH_CUSTOM_FULL => + 5, # allows fully formed OAuth headers to ignore browser auth refresh flow +}; + +sub oauth_types { + return ( OAUTH_DEFAULT, OAUTH_CUSTOM_CLIENT, OAUTH_CUSTOM_FULL ); +} + +1; diff --git a/YTMusicAPI/Constants.pm b/YTMusicAPI/Constants.pm new file mode 100644 index 0000000..bc62f99 --- /dev/null +++ b/YTMusicAPI/Constants.pm @@ -0,0 +1,57 @@ +package YTMusicAPI::Constants; + +use strict; +use warnings; + +use Exporter 'import'; + +use constant YTM_DOMAIN => 'https://music.youtube.com'; +use constant YTM_BASE_API => YTM_DOMAIN . '/youtubei/v1/'; +use constant YTM_PARAMS => '?alt=json'; +use constant YTM_PARAMS_KEY => '&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'; +use constant USER_AGENT => +'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0'; + +our $SUPPORTED_LANGUAGES = [ + 'ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'ja', + 'ko', 'nl', 'pt', 'ru', 'tr', 'ur', 'zh_CN', 'zh_TW' +]; + +our $SUPPORTED_LOCATIONS = [ + 'AE', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BD', 'BE', 'BG', 'BH', 'BO', 'BR', + 'BY', 'CA', 'CH', 'CL', 'CO', 'CR', 'CY', 'CZ', 'DE', 'DK', 'DO', 'DZ', + 'EC', 'EE', 'EG', 'ES', 'FI', 'FR', 'GB', 'GE', 'GH', 'GR', 'GT', 'HK', + 'HN', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', 'IS', 'IT', 'JM', 'JO', + 'JP', 'KE', 'KH', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LI', 'LK', 'LT', 'LU', + 'LV', 'LY', 'MA', 'ME', 'MK', 'MT', 'MX', 'MY', 'NG', 'NI', 'NL', 'NO', + 'NP', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', 'PL', 'PR', 'PT', 'PY', + 'QA', 'RO', 'RS', 'RU', 'SA', 'SE', 'SG', 'SI', 'SK', 'SN', 'SV', 'TH', + 'TN', 'TR', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'VE', 'VN', 'YE', 'ZA', + 'ZW' +]; + +use constant OAUTH_CLIENT_ID => + '861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com'; +use constant OAUTH_CLIENT_SECRET => 'SboVhoG9s0rNafixCSGGKXAT'; +use constant OAUTH_SCOPE => 'https://www.googleapis.com/auth/youtube'; +use constant OAUTH_CODE_URL => 'https://www.youtube.com/o/oauth2/device/code'; +use constant OAUTH_TOKEN_URL => 'https://oauth2.googleapis.com/token'; +use constant OAUTH_USER_AGENT => USER_AGENT . ' Cobalt/Version'; + +our @EXPORT = qw( + YTM_DOMAIN + YTM_BASE_API + YTM_PARAMS + YTM_PARAMS_KEY + USER_AGENT + $SUPPORTED_LANGUAGES + $SUPPORTED_LOCATIONS + OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET + OAUTH_SCOPE + OAUTH_CODE_URL + OAUTH_TOKEN_URL + OAUTH_USER_AGENT +); + +1; diff --git a/YTMusicAPI/Helpers.pm b/YTMusicAPI/Helpers.pm new file mode 100644 index 0000000..4b177b8 --- /dev/null +++ b/YTMusicAPI/Helpers.pm @@ -0,0 +1,128 @@ +package YTMusicAPI::Helpers; + +use strict; +use warnings; + +use Exporter 'import'; +use POSIX qw(strftime locale_h); +use LWP::UserAgent; +use JSON; +use HTTP::Cookies; +use Digest::SHA qw(sha1_hex); +use Time::HiRes qw(time); +use Unicode::Normalize; +use locale; + +use YTMusicAPI::Constants qw(YTM_DOMAIN USER_AGENT); + +sub initialize_headers { + return { + 'user-agent' => USER_AGENT, + 'accept' => '*/*', + 'accept-encoding' => 'gzip, deflate', + 'content-type' => 'application/json', + 'content-encoding' => 'gzip', + 'origin' => YTM_DOMAIN, + }; +} + +sub initialize_context { + return { + 'context' => { + 'client' => { + 'clientName' => "WEB_REMIX", + 'clientVersion' => "1." + . strftime( "%Y%m%d", gmtime ) + . ".01.00", + }, + 'user' => {}, + } + }; +} + +sub get_visitor_id { + my ($content) = @_; + my @matches = $content =~ /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g; + my $visitor_id = ""; + + if (@matches) { + my $ytcfg = decode_json( $matches[0] ); + $visitor_id = $ytcfg->{"VISITOR_DATA"} // ""; + } + + return $visitor_id; +} + +sub sapisid_from_cookie { + my ($raw_cookie) = @_; + my $cookie_jar = HTTP::Cookies->new(); + $raw_cookie =~ s/\"//g; # Replace double quotes + $cookie_jar->parse_headers( { 'set-cookie' => $raw_cookie } ); + my $sapisid = $cookie_jar->get_cookie("__Secure-3PAPISID"); + return $sapisid; +} + +sub get_authorization { + my ($auth) = @_; + my $unix_timestamp = int(time); + my $digest = sha1_hex( $unix_timestamp . " " . $auth ); + return "SAPISIDHASH " . $unix_timestamp . "_" . $digest; +} + +sub to_int { + my ($string) = @_; + $string = NFKD($string); + my $number_string = $string =~ s/\D//gr; + setlocale( LC_ALL, "en_US.UTF-8" ); + my $int_value; + + eval { $int_value = atoi($number_string); }; + + if ($@) { + $number_string =~ s/,//g; + $int_value = int($number_string); + } + + return $int_value; +} + +sub sum_total_duration { + my ($item) = @_; + + return 0 unless exists $item->{tracks}; + + my $total_duration = + sum( map { exists $_->{duration_seconds} ? $_->{duration_seconds} : 0 } + @{ $item->{tracks} } ); + + return $total_duration; +} + +sub array_index { + my ( $list, $value ) = @_; + my $index = 0; + my $found_index = -1; + + foreach my $type (@$list) { + if ( $type eq $value ) { + $found_index = $index; + last; + } + $index++; + } + + return $found_index; +} + +our @EXPORT = qw( + initialize_headers + initialize_context + get_visitor_id + sapisid_from_cookie + get_authorization + to_int + sum_total_duration + array_index +); + +1; diff --git a/YTMusicAPI/Mixins/SearchMixin.pm b/YTMusicAPI/Mixins/SearchMixin.pm new file mode 100644 index 0000000..664cfe1 --- /dev/null +++ b/YTMusicAPI/Mixins/SearchMixin.pm @@ -0,0 +1,177 @@ +package YTMusicAPI::Mixins::SearchMixin; + +use strict; +use warnings; + +use Moose::Role; +use Data::Dumper; + +use YTMusicAPI::Helpers; +use YTMusicAPI::Navigation; +use YTMusicAPI::Parsers::Search; + +sub search { + my ( $self, $query, $filter, $scope, $limit, $ignore_spelling ) = @_; + $filter //= undef; + $scope //= undef; + $limit //= 20; + $ignore_spelling //= 0; + + my $body = { 'query' => $query }; + my $endpoint = "search"; + my $search_results = []; + my $filters = [ + "albums", "artists", + "playlists", "community_playlists", + "featured_playlists", "songs", + "videos", "profiles", + "podcasts", "episodes", + ]; + + if ( $filter and !grep { $_ eq $filter } @$filters ) { + die "Invalid filter provided. " + . "Please use one of the following filters or leave out the parameter: " + . join( ", ", @$filters ); + } + + my $scopes = [ "library", "uploads" ]; + if ( $scope and !grep { $_ eq $scope } @$scopes ) { + die "Invalid scope provided. " + . "Please use one of the following scopes or leave out the parameter: " + . join( ", ", @$scopes ); + } + + if ( $scope and $scope eq @$scopes[1] and $filter ) { + die "No filter can be set when searching uploads. " + . "Please unset the filter parameter when scope is set to uploads."; + } + + if ( $scope + and $scope eq @$scopes[0] + and grep { $_ eq $filter } @$filters[ 3 .. 4 ] ) + { + die "$filter cannot be set when searching library. " + . "Please use one of the following filters or leave out the parameter: " + . join( ", ", ( @$filters[ 0 .. 2 ], @$filters[ 5 .. $#$filters ] ) ); + } + + my $params = get_search_params( $filter, $scope, $ignore_spelling ); + if ($params) { + $body->{"params"} = $params; + } + + my $response = $self->_send_request( $endpoint, $body ); + + if ( !exists $response->{"contents"} ) { + return $search_results; + } + + my $results; + + if ( exists $response->{"contents"}{"tabbedSearchResultsRenderer"} ) { + my $tab_index = + !( $scope or $filter ) ? 0 : array_index( $scopes, $scope ) + 1; + $results = + $response->{"contents"}{"tabbedSearchResultsRenderer"}{"tabs"} + [$tab_index]{"tabRenderer"}{"content"}; + } + else { + $results = $response->{"contents"}; + } + + $results = nav( $results, $SECTION_LIST ); + + if ( scalar @{$results} == 1 + and exists $results->[0]->{"itemSectionRenderer"} ) + { + return $search_results; + } + + if ( $filter and index( $filter, "playlists" ) != -1 ) { + $filter = "playlists"; + } + elsif ( $scope and $scope == $scopes->[1] ) { + $filter = $scopes->[1]; + } + + foreach my $res ( @{$results} ) { + my $category = undef; + my $type = undef; + if ( exists $res->{"musicCardShelfRenderer"} ) { + my $top_result = parse_top_result( $res->{"musicCardShelfRenderer"}, + $self->{parser}->get_search_result_types() ); + push( @$search_results, $top_result ); + if ( $results = + nav( $res, [ "musicCardShelfRenderer", "contents" ], 1 ) ) + { + # category "more from youtube" is missing sometimes + if ( exists $results->[0]->{"messageRenderer"} ) { + $category = nav( shift(@$results), + [ "messageRenderer", @$TEXT_RUN_TEXT ] ); + } + } + else { + next; + } + } + elsif ( exists $res->{"musicShelfRenderer"} ) { + $results = $res->{"musicShelfRenderer"}{"contents"}; + my $type_filter = $filter; + $category = nav( $res, [ @$MUSIC_SHELF, @$TITLE_TEXT ], 1 ); + if ( !$type_filter and $scope and $scope eq $scopes->[0] ) { + $type_filter = $category; + } + $type = substr( $type_filter, 0, -1 ) if $type_filter; + $type = lc($type) if $type; + } + else { + next; + } + my $search_result_types = $self->{parser}->get_search_result_types(); + push( + @$search_results, + parse_search_results( + $results, $search_result_types, $type, $category + ) + ); + if ($filter) { # if filter is set, there are continuations + my $request_func = sub { + my ($additionalParams) = @_; + return $self->_send_request( $endpoint, $body, + $additionalParams ); + }; + my $parse_func = sub { + my ($contents) = @_; + return parse_search_results( $contents, $search_result_types, + $type, $category ); + }; + push( + @$search_results, + get_continuations( + $res->{"musicShelfRenderer"}, + "musicShelfContinuation", + $limit - scalar(@$search_results), + $request_func, + $parse_func + ) + ); + } + } + + return $search_results; +} + +sub get_search_suggestions { + my ( $self, $query, $detailed_runs ) = @_; + + $detailed_runs //= 0; + + my %body = ( "input" => $query ); + my $endpoint = "music/get_search_suggestions"; + my $response = $self->_send_request( $endpoint, %body ); + my $search_suggestions = + parse_search_suggestions( $response, $detailed_runs ); + return $search_suggestions; +} + +1; diff --git a/YTMusicAPI/Navigation.pm b/YTMusicAPI/Navigation.pm new file mode 100644 index 0000000..1568884 --- /dev/null +++ b/YTMusicAPI/Navigation.pm @@ -0,0 +1,253 @@ +package YTMusicAPI::Navigation; + +use strict; +use warnings; + +use Exporter 'import'; + +our $CONTENT = [ "contents", 0 ]; +our $RUN_TEXT = [ "runs", 0, "text" ]; +our $TAB_CONTENT = [ "tabs", 0, "tabRenderer", "content" ]; +our $TAB_1_CONTENT = [ "tabs", 1, "tabRenderer", "content" ]; +our $TWO_COLUMN_RENDERER = [ "contents", "twoColumnBrowseResultsRenderer" ]; +our $SINGLE_COLUMN = [ "contents", "singleColumnBrowseResultsRenderer" ]; +our $SINGLE_COLUMN_TAB = [ @$SINGLE_COLUMN, @$TAB_CONTENT ]; +our $SECTION = ["sectionListRenderer"]; +our $SECTION_LIST = [ @$SECTION, "contents" ]; +our $SECTION_LIST_ITEM = [ @$SECTION, @$CONTENT ]; +our $RESPONSIVE_HEADER = ["musicResponsiveHeaderRenderer"]; +our $ITEM_SECTION = [ "itemSectionRenderer", @$CONTENT ]; +our $MUSIC_SHELF = ["musicShelfRenderer"]; +our $GRID = ["gridRenderer"]; +our $GRID_ITEMS = [ @$GRID, "items" ]; +our $MENU = [ "menu", "menuRenderer" ]; +our $MENU_ITEMS = [ @$MENU, "items" ]; +our $MENU_LIKE_STATUS = + [ @$MENU, "topLevelButtons", 0, "likeButtonRenderer", "likeStatus" ]; +our $MENU_SERVICE = [ "menuServiceItemRenderer", "serviceEndpoint" ]; +our $TOGGLE_MENU = "toggleMenuServiceItemRenderer"; +our $OVERLAY_RENDERER = + [ "musicItemThumbnailOverlayRenderer", "content", "musicPlayButtonRenderer" ]; +our $PLAY_BUTTON = [ "overlay", @$OVERLAY_RENDERER ]; +our $NAVIGATION_BROWSE = [ "navigationEndpoint", "browseEndpoint" ]; +our $NAVIGATION_BROWSE_ID = [ @$NAVIGATION_BROWSE, "browseId" ]; +our $PAGE_TYPE = [ + "browseEndpointContextSupportedConfigs", + "browseEndpointContextMusicConfig", + "pageType" +]; +our $WATCH_VIDEO_ID = [ "watchEndpoint", "videoId" ]; +our $NAVIGATION_VIDEO_ID = [ "navigationEndpoint", @$WATCH_VIDEO_ID ]; +our $QUEUE_VIDEO_ID = [ "queueAddEndpoint", "queueTarget", "videoId" ]; +our $NAVIGATION_PLAYLIST_ID = + [ "navigationEndpoint", "watchEndpoint", "playlistId" ]; +our $WATCH_PID = [ "watchPlaylistEndpoint", "playlistId" ]; +our $NAVIGATION_WATCH_PLAYLIST_ID = [ "navigationEndpoint", @$WATCH_PID ]; +our $NAVIGATION_VIDEO_TYPE = [ + "watchEndpoint", "watchEndpointMusicSupportedConfigs", + "watchEndpointMusicConfig", "musicVideoType", +]; +our $ICON_TYPE = [ "icon", "iconType" ]; +our $TOGGLED_BUTTON = [ "toggleButtonRenderer", "isToggled" ]; +our $TITLE = [ "title", "runs", 0 ]; +our $TITLE_TEXT = [ "title", @$RUN_TEXT ]; +our $TEXT_RUNS = [ "text", "runs" ]; +our $TEXT_RUN = [ @$TEXT_RUNS, 0 ]; +our $TEXT_RUN_TEXT = [ @$TEXT_RUN, "text" ]; +our $SUBTITLE = [ "subtitle", @$RUN_TEXT ]; +our $SUBTITLE_RUNS = [ "subtitle", "runs" ]; +our $SUBTITLE_RUN = [ @$SUBTITLE_RUNS, 0 ]; +our $SUBTITLE2 = [ @$SUBTITLE_RUNS, 2, "text" ]; +our $SUBTITLE3 = [ @$SUBTITLE_RUNS, 4, "text" ]; +our $THUMBNAIL = [ "thumbnail", "thumbnails" ]; +our $THUMBNAILS = [ "thumbnail", "musicThumbnailRenderer", @$THUMBNAIL ]; +our $THUMBNAIL_RENDERER = + [ "thumbnailRenderer", "musicThumbnailRenderer", @$THUMBNAIL ]; +our $THUMBNAIL_OVERLAY = [ + "thumbnailOverlay", @$OVERLAY_RENDERER, + "playNavigationEndpoint", @$WATCH_PID +]; +our $THUMBNAIL_CROPPED = + [ "thumbnail", "croppedSquareThumbnailRenderer", @$THUMBNAIL ]; +our $FEEDBACK_TOKEN = [ "feedbackEndpoint", "feedbackToken" ]; +our $BADGE_PATH = [ + 0, "musicInlineBadgeRenderer", + "accessibilityData", "accessibilityData", + "label" +]; +our $BADGE_LABEL = [ "badges", @$BADGE_PATH ]; +our $SUBTITLE_BADGE_LABEL = [ "subtitleBadges", @$BADGE_PATH ]; +our $CATEGORY_TITLE = + [ "musicNavigationButtonRenderer", "buttonText", @$RUN_TEXT ]; +our $CATEGORY_PARAMS = [ + "musicNavigationButtonRenderer", "clickCommand", + "browseEndpoint", "params" +]; +our $MMRIR = "musicMultiRowListItemRenderer"; +our $MRLIR = "musicResponsiveListItemRenderer"; +our $MTRIR = "musicTwoRowItemRenderer"; +our $MNIR = "menuNavigationItemRenderer"; +our $TASTE_PROFILE_ITEMS = [ "contents", "tastebuilderRenderer", "contents" ]; +our $TASTE_PROFILE_ARTIST = [ "title", "runs" ]; +our $SECTION_LIST_CONTINUATION = + [ "continuationContents", "sectionListContinuation" ]; +our $MENU_PLAYLIST_ID = + [ @$MENU_ITEMS, 0, $MNIR, @$NAVIGATION_WATCH_PLAYLIST_ID ]; +our $MULTI_SELECT = ["musicMultiSelectMenuItemRenderer"]; +our $HEADER_DETAIL = [ "header", "musicDetailHeaderRenderer" ]; +our $HEADER_SIDE = [ "header", "musicSideAlignedItemRenderer" ]; +our $HEADER_MUSIC_VISUAL = [ "header", "musicVisualHeaderRenderer" ]; +our $DESCRIPTION_SHELF = ["musicDescriptionShelfRenderer"]; +our $DESCRIPTION = [ "description", @$RUN_TEXT ]; +our $CAROUSEL = ["musicCarouselShelfRenderer"]; +our $IMMERSIVE_CAROUSEL = ["musicImmersiveCarouselShelfRenderer"]; +our $CAROUSEL_CONTENTS = [ @$CAROUSEL, "contents" ]; +our $CAROUSEL_TITLE = + [ "header", "musicCarouselShelfBasicHeaderRenderer", @$TITLE ]; +our $CARD_SHELF_TITLE = + [ "header", "musicCardShelfHeaderBasicRenderer", @$TITLE_TEXT ]; +our $FRAMEWORK_MUTATIONS = + [ "frameworkUpdates", "entityBatchUpdate", "mutations" ]; + +sub nav { + my ( $root, $items, $none_if_absent ) = @_; + $none_if_absent //= 0; + + if (!ref($items)) { + $root = $root->{$items}; + return $root; + } + + foreach my $k (@$items) { + if ( ref($root) eq 'HASH' && exists $root->{$k} ) { + $root = $root->{$k}; + } + elsif ( ref($root) eq 'ARRAY' && $k =~ /^\d+$/ && $k < @$root ) { + $root = $root->[$k]; + } + else { + if ($none_if_absent) { + return undef; + } + else { + die "Unable to find '$k' using path @$items on $root"; + } + } + } + + return $root; +} + +sub find_object_by_key { + my ( $object_list, $key, $nested, $is_key ) = @_; + + foreach my $item (@$object_list) { + if ($nested) { + $item = $item->{$nested}; + } + if ( exists $item->{$key} ) { + return $is_key ? $item->{$key} : $item; + } + } + + return undef; +} + +sub find_objects_by_key { + my ( $object_list, $key, $nested ) = @_; + my @objects; + + foreach my $item (@$object_list) { + if ($nested) { + $item = $item->{$nested}; + } + if ( exists $item->{$key} ) { + push @objects, $item; + } + } + + return \@objects; +} + +our @EXPORT = qw( + $CONTENT + $RUN_TEXT + $TAB_CONTENT + $TAB_1_CONTENT + $TWO_COLUMN_RENDERER + $SINGLE_COLUMN + $SINGLE_COLUMN_TAB + $SECTION + $SECTION_LIST + $SECTION_LIST_ITEM + $RESPONSIVE_HEADER + $ITEM_SECTION + $MUSIC_SHELF + $GRID + $GRID_ITEMS + $MENU + $MENU_ITEMS + $MENU_LIKE_STATUS + $MENU_SERVICE + $TOGGLE_MENU + $OVERLAY_RENDERER + $PLAY_BUTTON + $NAVIGATION_BROWSE + $NAVIGATION_BROWSE_ID + $PAGE_TYPE + $WATCH_VIDEO_ID + $NAVIGATION_VIDEO_ID + $QUEUE_VIDEO_ID + $NAVIGATION_PLAYLIST_ID + $WATCH_PID + $NAVIGATION_WATCH_PLAYLIST_ID + $NAVIGATION_VIDEO_TYPE + $ICON_TYPE + $TOGGLED_BUTTON + $TITLE + $TITLE_TEXT + $TEXT_RUNS + $TEXT_RUN + $TEXT_RUN_TEXT + $SUBTITLE + $SUBTITLE_RUNS + $SUBTITLE_RUN + $SUBTITLE2 + $SUBTITLE3 + $THUMBNAIL + $THUMBNAILS + $THUMBNAIL_RENDERER + $THUMBNAIL_OVERLAY + $THUMBNAIL_CROPPED + $FEEDBACK_TOKEN + $BADGE_PATH + $BADGE_LABEL + $SUBTITLE_BADGE_LABEL + $CATEGORY_TITLE + $CATEGORY_PARAMS + $MMRIR + $MRLIR + $MTRIR + $MNIR + $TASTE_PROFILE_ITEMS + $TASTE_PROFILE_ARTIST + $SECTION_LIST_CONTINUATION + $MENU_PLAYLIST_ID + $MULTI_SELECT + $HEADER_DETAIL + $HEADER_SIDE + $HEADER_MUSIC_VISUAL + $DESCRIPTION_SHELF + $DESCRIPTION + $CAROUSEL + $IMMERSIVE_CAROUSEL + $CAROUSEL_CONTENTS + $CAROUSEL_TITLE + $CARD_SHELF_TITLE + $FRAMEWORK_MUTATIONS + nav + find_object_by_key + find_objects_by_key +); + +1; diff --git a/YTMusicAPI/Parsers/Albums.pm b/YTMusicAPI/Parsers/Albums.pm new file mode 100644 index 0000000..c4f1072 --- /dev/null +++ b/YTMusicAPI/Parsers/Albums.pm @@ -0,0 +1,56 @@ +package YTMusicAPI::Parsers::Albums; + +use strict; +use warnings; + +use YTMusicAPI::Navigation; + +sub parse_album_header { + my ($response) = @_; + my $header = nav( $response, $HEADER_DETAIL ); + my $album = { + title => nav( $header, $TITLE_TEXT ), + type => nav( $header, $SUBTITLE ), + thumbnails => nav( $header, $THUMBNAIL_CROPPED ), + isExplicit => defined( nav( $header, $SUBTITLE_BADGE_LABEL, 1 ) ) + ? 1 + : 0, + }; + + if ( exists $header->{description} ) { + $album->{description} = $header->{description}->{runs}->[0]->{text}; + } + + my $album_info = parse_song_runs( + $header->{subtitle}->{runs}->[ 2 .. $#{ $header->{subtitle}->{runs} } ] + ); + @$album{ keys %$album_info } = values %$album_info; + + if ( scalar @{ $header->{secondSubtitle}->{runs} } > 1 ) { + $album->{trackCount} = + to_int( $header->{secondSubtitle}->{runs}->[0]->{text} ); + $album->{duration} = $header->{secondSubtitle}->{runs}->[2]->{text}; + } + else { + $album->{duration} = $header->{secondSubtitle}->{runs}->[0]->{text}; + } + + my $menu = nav( $header, $MENU ); + my $toplevel = $menu->{topLevelButtons}; + $album->{audioPlaylistId} = + nav( $toplevel, [ 0, 'buttonRenderer', $NAVIGATION_WATCH_PLAYLIST_ID ], + 1 ); + unless ( $album->{audioPlaylistId} ) { + $album->{audioPlaylistId} = + nav( $toplevel, [ 0, 'buttonRenderer', $NAVIGATION_PLAYLIST_ID ], 1 ); + } + my $service = + nav( $toplevel, [ 1, 'buttonRenderer', 'defaultServiceEndpoint' ], 1 ); + if ($service) { + $album->{likeStatus} = parse_like_status($service); + } + + return $album; +} + +1; diff --git a/YTMusicAPI/Parsers/Parser.pm b/YTMusicAPI/Parsers/Parser.pm new file mode 100644 index 0000000..562295e --- /dev/null +++ b/YTMusicAPI/Parsers/Parser.pm @@ -0,0 +1,78 @@ +package YTMusicAPI::Parsers::Parser; + +use strict; +use warnings; + +use YTMusicAPI::Navigation; + +sub new { + my ( $class, $language ) = @_; + + my $self = { lang => $language }; + + bless $self, $class; + + return $self; +} + +sub _ { + my ($input) = @_; + return $input; +} + +sub get_search_result_types { + my ($self) = @_; + + return [ + _("artist"), _("playlist"), _("song"), _("video"), + _("station"), _("profile"), _("podcast"), _("episode"), + ]; +} + +sub parse_channel_contents { + my ( $self, $results ) = @_; + + my @categories = ( + [ "albums", _("albums"), \&parse_album, $MTRIR ], + [ "singles", _("singles"), \&parse_single, $MTRIR ], + [ "shows", _("shows"), \&parse_album, $MTRIR ], + [ "videos", _("videos"), \&parse_video, $MTRIR ], + [ "playlists", _("playlists"), \&parse_playlist, $MTRIR ], + [ "related", _("related"), \&parse_related_artist, $MTRIR ], + [ "episodes", _("episodes"), \&parse_episode, $MMRIR ], + [ "podcasts", _("podcasts"), \&parse_podcast, $MTRIR ], + ); + + my %artist = {}; + foreach my $category_tuple (@categories) { + my ( $category, $category_local, $category_parser, $category_key ) = + @$category_tuple; + my @data = map { $_->{"musicCarouselShelfRenderer"} } + grep { + exists $_->{"musicCarouselShelfRenderer"} + && nav( $_, $CAROUSEL . $CAROUSEL_TITLE )->{"text"} =~ + /^$category_local$/i + } $results; + + if ( scalar @data > 0 ) { + $artist{$category} = { "browseId" => undef, "results" => [] }; + + if ( + exists nav( $data[0], $CAROUSEL_TITLE )->{"navigationEndpoint"} + ) + { + $artist{$category}->{"browseId"} = + nav( $data[0], $CAROUSEL_TITLE . $NAVIGATION_BROWSE_ID ); + $artist{$category}->{"params"} = nav( $data[0], + $CAROUSEL_TITLE . $NAVIGATION_BROWSE . ["params"], 1 ); + } + $artist{$category}->{"results"} = + parse_content_list( $data[0]->{"contents"}, + $category_parser, "key" => $category_key ); + } + } + + return %artist; +} + +1; diff --git a/YTMusicAPI/Parsers/Search.pm b/YTMusicAPI/Parsers/Search.pm new file mode 100644 index 0000000..f3aa068 --- /dev/null +++ b/YTMusicAPI/Parsers/Search.pm @@ -0,0 +1,397 @@ +package YTMusicAPI::Parsers::Search; + +use strict; +use warnings; + +use Exporter 'import'; + +use YTMusicAPI::Parsers::Utils; +use YTMusicAPI::Parsers::Songs; +use YTMusicAPI::Navigation; + +sub get_search_result_type { + my ( $result_type_local, $result_types_local ) = @_; + + if ( !$result_type_local ) { + return undef; + } + + my @result_types = ( + "artist", "playlist", "song", "video", + "station", "profile", "podcast", "episode" + ); + $result_type_local = lc $result_type_local; + my $result_type; + my $index = 0; + my $found_index = -1; + + # Search for index + foreach my $type (@$result_types_local) { + if ( $type eq $result_type_local ) { + $found_index = $index; + last; + } + $index++; + } + + if ( $found_index == -1 ) { + $result_type = "album"; + } + else { + $result_type = $result_types[$found_index]; + } + + return $result_type; +} + +sub parse_top_result { + my ( $data, $search_result_types ) = @_; + my $result_type = + get_search_result_type( nav( $data, $SUBTITLE ), $search_result_types ); + my $search_result = { + "category" => nav( $data, $CARD_SHELF_TITLE ), + "resultType" => $result_type + }; + + if ( $result_type eq "artist" ) { + my $subscribers = nav( $data, $SUBTITLE2, 1 ); + if ($subscribers) { + $search_result->{"subscribers"} = ( split " ", $subscribers )[0]; + } + + my $artist_info = parse_song_runs( nav( $data, [ "title", "runs" ] ) ); + @$search_result{ keys %$artist_info } = values %$artist_info; + } + + if ( $result_type eq "song" || $result_type eq "video" ) { + my $on_tap = $data->{"onTap"}; + if ($on_tap) { + $search_result->{"videoId"} = nav( $on_tap, $WATCH_VIDEO_ID ); + $search_result->{"videoType"} = + nav( $on_tap, $NAVIGATION_VIDEO_TYPE ); + } + } + + if ( grep { $_ eq $result_type } [ "song", "video", "album" ] ) { + $search_result->{"videoId"} = + nav( $data, [ "onTap", $WATCH_VIDEO_ID ], 1 ); + $search_result->{"videoType"} = + nav( $data, [ "onTap", $NAVIGATION_VIDEO_TYPE ], 1 ); + + $search_result->{"title"} = nav( $data, $TITLE_TEXT ); + my $runs = nav( $data, [ "subtitle", "runs" ] ); + my $song_info = parse_song_runs($runs); + @$search_result{ keys %$song_info } = values %$song_info; + } + + if ( $result_type eq "album" ) { + $search_result->{"browseId"} = + nav( $data, $TITLE . $NAVIGATION_BROWSE_ID, 1 ); + } + + if ( $result_type eq "playlist" ) { + $search_result->{"playlistId"} = nav( $data, $MENU_PLAYLIST_ID ); + $search_result->{"title"} = nav( $data, $TITLE_TEXT ); + my @x = nav( $data, [ "subtitle", "runs" ], 0 ); + $search_result->{"author"} = parse_song_artists_runs( $x[ 2 .. $#x ] ); + } + + $search_result->{"thumbnails"} = nav( $data, $THUMBNAILS, 1 ); + return $search_result; +} + +sub parse_search_result { + my ( $data, $search_result_types, $result_type, $category ) = @_; + my $default_offset = ( !$result_type || $result_type eq "album" ) ? 2 : 0; + my $search_result = { "category" => $category }; + my $video_type = nav( $data, + [ $PLAY_BUTTON, "playNavigationEndpoint", $NAVIGATION_VIDEO_TYPE ], 1 ); + if ( !$result_type && $video_type ) { + $result_type = $video_type eq "MUSIC_VIDEO_TYPE_ATV" ? "song" : "video"; + } + + unless ($result_type) { + $result_type = get_search_result_type( get_item_text( $data, 1 ), + $search_result_types ); + } + $search_result->{"resultType"} = $result_type; + + if ( $result_type ne "artist" ) { + $search_result->{"title"} = get_item_text( $data, 0 ); + } + + if ( $result_type eq "artist" ) { + $search_result->{"artist"} = get_item_text( $data, 0 ); + parse_menu_playlists( $data, $search_result ); + } + elsif ( $result_type eq "album" ) { + $search_result->{"type"} = get_item_text( $data, 1 ); + } + elsif ( $result_type eq "playlist" ) { + my $flex_item = get_flex_column_item( $data, 1 )->{"text"}->{"runs"}; + my $has_author = scalar(@$flex_item) == $default_offset + 3; + $search_result->{"itemCount"} = ( + split( + " ", + get_item_text( $data, 1, $default_offset + $has_author * 2 ) + ) + )[0]; + $search_result->{"author"} = + $has_author ? get_item_text( $data, 1, $default_offset ) : undef; + } + elsif ( $result_type eq "station" ) { + $search_result->{"videoId"} = nav( $data, $NAVIGATION_VIDEO_ID ); + $search_result->{"playlistId"} = nav( $data, $NAVIGATION_PLAYLIST_ID ); + } + elsif ( $result_type eq "profile" ) { + $search_result->{"name"} = get_item_text( $data, 1, 2, 1 ); + } + elsif ( $result_type eq "song" ) { + $search_result->{"album"} = undef; + if ( exists $data->{"menu"} ) { + my $toggle_menu = + find_object_by_key( nav( $data, $MENU_ITEMS ), $TOGGLE_MENU ); + if ($toggle_menu) { + $search_result->{"inLibrary"} = + parse_song_library_status($toggle_menu); + $search_result->{"feedbackTokens"} = + parse_song_menu_tokens($toggle_menu); + } + } + } + elsif ( $result_type eq "upload" ) { + my $browse_id = nav( $data, $NAVIGATION_BROWSE_ID, 1 ); + if ( !$browse_id ) { # song result + my @flex_items = map { + nav( get_flex_column_item( $data, $_ ), [ "text", "runs" ], 1 ) + } ( 0 .. 1 ); + if ( $flex_items[0] ) { + $search_result->{"videoId"} = + nav( $flex_items[0]->[0], $NAVIGATION_VIDEO_ID, 1 ); + $search_result->{"playlistId"} = + nav( $flex_items[0]->[0], $NAVIGATION_PLAYLIST_ID, 1 ); + } + if ( $flex_items[1] ) { + my $song_runs = parse_song_runs( $flex_items[1] ); + @$search_result{ keys %$song_runs } = values %$song_runs; + } + $search_result->{"resultType"} = "song"; + } + else { + # artist or album result + $search_result->{"browseId"} = $browse_id; + if ( $search_result->{"browseId"} =~ /artist/ ) { + $search_result->{"resultType"} = "artist"; + } + else { + my $flex_item2 = get_flex_column_item( $data, 1 ); + my $i = 0; + my @runs = map { $_->{"text"} } + grep { $i++ % 2 == 0 } @{ $flex_item2->{"text"}->{"runs"} }; + if ( scalar(@runs) > 1 ) { + $search_result->{"artist"} = $runs[1]; + } + if ( scalar(@runs) > 2 ) { # date may be missing + $search_result->{"releaseDate"} = $runs[2]; + } + $search_result->{"resultType"} = "album"; + } + } + } + + if ( $result_type =~ /song|video|episode/ ) { + $search_result->{"videoId"} = nav( + $data, + [ + $PLAY_BUTTON, "playNavigationEndpoint", + "watchEndpoint", "videoId" + ], + 1 + ); + $search_result->{"videoType"} = $video_type; + } + + if ( grep { $_ eq $result_type } [ "song", "video", "album" ] ) { + $search_result->{"duration"} = undef; + $search_result->{"year"} = undef; + my $flex_item = get_flex_column_item( $data, 1 ); + my $runs = $flex_item->{"text"}->{"runs"}; + my $song_info = parse_song_runs($runs); + @$search_result{ keys %$song_info } = values %$song_info; + } + + if ( grep { $_ eq $result_type } + [ "artist", "album", "playlist", "profile", "podcast" ] ) + { + $search_result->{"browseId"} = nav( $data, $NAVIGATION_BROWSE_ID, 1 ); + } + + if ( grep { $_ eq $result_type } [ "song", "album" ] ) { + $search_result->{"isExplicit"} = + defined( nav( $data, $BADGE_LABEL, 1 ) ) ? 1 : undef; + } + + if ( $result_type eq "episode" ) { + my $flex_item = get_flex_column_item( $data, 1 ); + my $has_date = scalar( @{ nav( $flex_item, $TEXT_RUNS ) } ) > 1 ? 1 : 0; + $search_result->{"live"} = + nav( $data, [ "badges", 0, "liveBadgeRenderer" ], 1 ) ? 1 : undef; + if ($has_date) { + $search_result->{"date"} = nav( $flex_item, $TEXT_RUN_TEXT ); + } + $search_result->{"podcast"} = + parse_id_name( nav( $flex_item, [ "text", "runs", $has_date * 2 ] ) ); + } + + $search_result->{"thumbnails"} = nav( $data, $THUMBNAILS, 1 ); + + return $search_result; +} + +sub parse_search_results { + my ( $results, $search_result_types, $result_type, $category ) = @_; + my @parsed_results; + + foreach my $result (@$results) { + push @parsed_results, + parse_search_result( $result->{$MRLIR}, $search_result_types, + $result_type, $category ); + } + + return \@parsed_results; +} + +sub get_search_params { + my ( $filter, $scope, $ignore_spelling ) = @_; + my $filtered_param1 = "EgWKAQ"; + my $params; + my $param1; + my $param2; + my $param3; + + return $params unless defined $filter || defined $scope || $ignore_spelling; + + if ( $scope and $scope eq "uploads" ) { + $params = "agIYAw%3D%3D"; + } + + if ( $scope and $scope eq "library" ) { + if ($filter) { + $param1 = $filtered_param1; + $param2 = _get_param2($filter); + $param3 = "AWoKEAUQCRADEAoYBA%3D%3D"; + } + else { + $params = "agIYBA%3D%3D"; + } + } + + if ( !defined $scope && $filter ) { + if ( $filter eq "playlists" ) { + $params = "Eg-KAQwIABAAGAAgACgB"; + if ( !$ignore_spelling ) { + $params .= "MABqChAEEAMQCRAFEAo%3D"; + } + else { + $params .= "MABCAggBagoQBBADEAkQBRAK"; + } + } + elsif ( $filter =~ /playlists/ ) { + $param1 = "EgeKAQQoA"; + $param2 = $filter eq "featured_playlists" ? "Dg" : "EA"; + + if ( !$ignore_spelling ) { + $param3 = "BagwQDhAKEAMQBBAJEAU%3D"; + } + else { + $param3 = "BQgIIAWoMEA4QChADEAQQCRAF"; + } + } + else { + $param1 = $filtered_param1; + $param2 = _get_param2($filter); + $param3 = + $ignore_spelling + ? "AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D" + : "AWoMEA4QChADEAQQCRAF"; + } + } + + if ( !defined $scope && !defined $filter && $ignore_spelling ) { + $params = "EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D"; + } + + return $params if defined $params; + return $param1 . $param2 . $param3 + if defined $param1 && defined $param2 && defined $param3; +} + +sub _get_param2 { + my ($filter) = @_; + my $filter_params = { + "songs" => "II", + "videos" => "IQ", + "albums" => "IY", + "artists" => "Ig", + "playlists" => "Io", + "profiles" => "JY", + "podcasts" => "JQ", + "episodes" => "JI", + }; + return $filter_params->{$filter}; +} + +sub parse_search_suggestions { + my ( $results, $detailed_runs ) = @_; + + return [] + unless $results->{"contents"}[0]{"searchSuggestionsSectionRenderer"} + {"contents"}; + + my $raw_suggestions = + $results->{"contents"}[0]{"searchSuggestionsSectionRenderer"}{"contents"}; + my @suggestions = (); + + foreach my $raw_suggestion (@$raw_suggestions) { + my ( $suggestion_content, $from_history ); + + if ( exists $raw_suggestion->{"historySuggestionRenderer"} ) { + $suggestion_content = + $raw_suggestion->{"historySuggestionRenderer"}; + $from_history = 1; + } + else { + $suggestion_content = $raw_suggestion->{"searchSuggestionRenderer"}; + $from_history = 0; + } + + my $text = $suggestion_content->{"navigationEndpoint"}{"searchEndpoint"} + {"query"}; + my $runs = $suggestion_content->{"suggestion"}{"runs"}; + + if ($detailed_runs) { + push @suggestions, + { + "text" => $text, + "runs" => $runs, + "fromHistory" => $from_history + }; + } + else { + push @suggestions, $text; + } + } + + return \@suggestions; +} + +our @EXPORT = qw( + get_search_result_type + parse_top_result + parse_search_result + parse_search_results + get_search_params + parse_search_suggestions +); + +1; diff --git a/YTMusicAPI/Parsers/Songs.pm b/YTMusicAPI/Parsers/Songs.pm new file mode 100644 index 0000000..f00b351 --- /dev/null +++ b/YTMusicAPI/Parsers/Songs.pm @@ -0,0 +1,155 @@ +package YTMusicAPI::Parsers::Songs; + +use strict; +use warnings; + +use Exporter 'import'; +use Regexp::Common qw(number); + +use YTMusicAPI::Parsers::Utils; +use YTMusicAPI::Navigation; + +sub parse_song_artists { + my ( $data, $index ) = @_; + my $flex_item = get_flex_column_item( $data, $index ); + + if ( !$flex_item ) { + return undef; + } + else { + my $runs = $flex_item->{"text"}{"runs"}; + return parse_song_artists_runs($runs); + } +} + +sub parse_song_artists_runs { + my ($runs) = @_; + my @artists = (); + + for my $j ( 0 .. int( @$runs / 2 ) ) { + push @artists, + { + "name" => $runs->[ $j * 2 ]{"text"}, + "id" => nav( $runs->[ $j * 2 ], $NAVIGATION_BROWSE_ID, 1 ) + }; + } + return \@artists; +} + +sub parse_song_runs { + my ($runs) = @_; + my %parsed = ( "artists" => [] ); + my $i = 0; + + for my $run (@$runs) { + if ( $i % 2 ) { # uneven items are always separators + $i++; + next; + } + + my $text = $run->{"text"}; + + if ( exists $run->{"navigationEndpoint"} ) { # artist or album + my $item = { + "name" => $text, + "id" => nav( $run, $NAVIGATION_BROWSE_ID, 1 ) + }; + + if ( + $item->{"id"} + && ( $item->{"id"} =~ /^MPRE/ + || $item->{"id"} =~ /release_detail/ ) + ) + { # album + $parsed{"album"} = $item; + } + else { # artist + push @{ $parsed{"artists"} }, $item; + } + + } + else { + # note: YT uses non-breaking space \xa0 to separate number and magnitude + if ( $text =~ /^\d([^ ])* [^ ]*$/ && $i > 0 ) { + ( $parsed{"views"} ) = split( / /, $text, 2 ); + } + elsif ( $text =~ /^(\d+:)*\d+:\d+$/ ) { + $parsed{"duration"} = $text; + $parsed{"duration_seconds"} = parse_duration($text); + } + elsif ( $text =~ /^\d{4}$/ ) { + $parsed{"year"} = $text; + } + else { # artist without id + push @{ $parsed{"artists"} }, + { "name" => $text, "id" => undef }; + } + } + + $i++; + } + + return \%parsed; +} + +sub parse_song_album { + my ( $data, $index ) = @_; + my $flex_item = get_flex_column_item( $data, $index ); + my $browse_id = nav( $flex_item, $TEXT_RUN + $NAVIGATION_BROWSE_ID, 1 ); + return !$flex_item + ? undef + : { "name" => get_item_text( $data, $index ), "id" => $browse_id }; +} + +sub parse_song_library_status { + my ($item) = @_; + my $library_status = + nav( $item, [ $TOGGLE_MENU, "defaultIcon", "iconType" ], 1 ); + return $library_status eq "LIBRARY_SAVED"; +} + +sub parse_song_menu_tokens { + my ($item) = @_; + my $toggle_menu = $item->{$TOGGLE_MENU}; + + my $library_add_token = + nav( $toggle_menu, [ "defaultServiceEndpoint", $FEEDBACK_TOKEN ], 1 ); + my $library_remove_token = + nav( $toggle_menu, [ "toggledServiceEndpoint", $FEEDBACK_TOKEN ], 1 ); + + my $in_library = parse_song_library_status($item); + if ($in_library) { + my $tmp = $library_remove_token; + $library_add_token = $library_remove_token; + $library_remove_token = $tmp; + } + + return { "add" => $library_add_token, "remove" => $library_remove_token }; +} + +sub parse_like_status { + my ($service) = @_; + my @status = ( "LIKE", "INDIFFERENT" ); + + my $status_index = 0; + for my $i ( 0 .. $#status ) { + if ( $status[$i] eq $service->{"likeEndpoint"}{"status"} ) { + $status_index = $i; + last; + } + } + + return $status[ $status_index - 1 ]; +} + +our @EXPORT = qw( + parse_song_artists + parse_song_artists_runs + parse_song_runs + parse_song_album + parse_song_library_status + parse_song_menu_tokens + parse_like_status +); + +1; diff --git a/YTMusicAPI/Parsers/Utils.pm b/YTMusicAPI/Parsers/Utils.pm new file mode 100644 index 0000000..b18d72f --- /dev/null +++ b/YTMusicAPI/Parsers/Utils.pm @@ -0,0 +1,140 @@ +package YTMusicAPI::Parsers::Utils; + +use strict; +use warnings; + +use Exporter 'import'; + +use YTMusicAPI::Navigation; + +sub parse_menu_playlists { + my ( $data, $result ) = @_; + my $watch_menu = find_objects_by_key( nav( $data, \@$MENU_ITEMS ), $MNIR ); + + foreach my $item ( map { $_->{$MNIR} } @$watch_menu ) { + my $icon = nav( $item, \@$ICON_TYPE, 1 ); + my $watch_key; + + if ( $icon eq "MUSIC_SHUFFLE" ) { + $watch_key = "shuffleId"; + } + elsif ( $icon eq "MIX" ) { + $watch_key = "radioId"; + } + else { + next; + } + + my $watch_id = + nav( $item, + [ "navigationEndpoint", "watchPlaylistEndpoint", "playlistId" ], + 1 ); + unless ($watch_id) { + $watch_id = nav( $item, + [ "navigationEndpoint", "watchEndpoint", "playlistId" ], 1 ); + } + + if ($watch_id) { + $result->{$watch_key} = $watch_id; + } + } +} + +sub get_item_text { + my ( $item, $index, $run_index, $none_if_absent ) = @_; + $run_index //= 0; + $none_if_absent //= 0; + + my $column = get_flex_column_item( $item, $index ); + return unless $column; + + if ( $none_if_absent + && scalar( @{ $column->{text}->{runs} } ) < $run_index + 1 ) + { + return; + } + + return $column->{text}->{runs}->[$run_index]->{text}; +} + +sub get_flex_column_item { + my ( $item, $index ) = @_; + + if ( scalar( @{ $item->{'flexColumns'} } ) <= $index + || ! + exists $item->{'flexColumns'}->[$index] + ->{'musicResponsiveListItemFlexColumnRenderer'}->{'text'} + || ! + exists $item->{'flexColumns'}->[$index] + ->{'musicResponsiveListItemFlexColumnRenderer'}->{'text'}->{'runs'} ) + { + return undef; + } + + return $item->{'flexColumns'}->[$index] + ->{'musicResponsiveListItemFlexColumnRenderer'}; +} + +sub get_fixed_column_item { + my ( $item, $index ) = @_; + + if ( !exists $item->{'fixedColumns'}->[$index] + ->{'musicResponsiveListItemFixedColumnRenderer'}->{'text'} + || ! + exists $item->{'fixedColumns'}->[$index] + ->{'musicResponsiveListItemFixedColumnRenderer'}->{'text'}->{'runs'} ) + { + return undef; + } + + return $item->{'fixedColumns'}->[$index] + ->{'musicResponsiveListItemFixedColumnRenderer'}; +} + +sub get_dot_separator_index { + my ($runs) = @_; + my $index = 0; + + foreach my $run (@$runs) { + last if ( $run->{"text"} eq " • " ); + $index++; + } + + return $index == scalar @$runs ? $index : $index; +} + +sub parse_duration { + my ($duration) = @_; + return $duration unless defined $duration; + + my @mapped_increments = ( [ 1, 60, 3600 ], reverse split /:/, $duration ); + my $seconds = 0; + + while ( my ( $multiplier, $time ) = splice( @mapped_increments, 0, 2 ) ) { + $seconds += $multiplier * $time; + } + + return $seconds; +} + +sub parse_id_name { + my ($sub_run) = @_; + return { + "id" => nav( + $sub_run, [ "navigationEndpoint", "browseEndpoint", "browseId" ], 1 + ), + "name" => nav( $sub_run, ["text"], 1 ), + }; +} + +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 +); + +1; diff --git a/YTMusicAPI/YTMusic.pm b/YTMusicAPI/YTMusic.pm new file mode 100644 index 0000000..621aae0 --- /dev/null +++ b/YTMusicAPI/YTMusic.pm @@ -0,0 +1,174 @@ +package YTMusicAPI::YTMusic; + +use strict; +use warnings; + +use Moose; +with 'YTMusicAPI::Mixins::SearchMixin'; + +use JSON; +use LWP::UserAgent; +use HTTP::Cookies; +use URI::Escape; +use Encode qw(encode_utf8); +use Data::Dumper; + +use YTMusicAPI::Constants qw( + $SUPPORTED_LANGUAGES + $SUPPORTED_LOCATIONS + USER_AGENT + YTM_BASE_API + YTM_DOMAIN + YTM_PARAMS + YTM_PARAMS_KEY +); + +use YTMusicAPI::Helpers; +use YTMusicAPI::Parsers::Parser; +use YTMusicAPI::Auth::AuthTypes; + +sub new { + my ( $class, $args ) = @_; + my $self = { + auth => $args->{auth} // undef, + user => $args->{user} // undef, + requests_session => $args->{requests_session} // 1, + proxies => $args->{proxies} // undef, + language => $args->{language} // 'en', + location => $args->{location} // '', + oauth_credentials => $args->{oauth_credentials} // undef, + _base_headers => undef, + _headers => undef, + _input_dict => undef, + _token => undef, + _session => undef, + _proxies => undef, + }; + + bless $self, $class; + + $self->{auth_type} = AuthType::UNAUTHORIZED; + + if ( UNIVERSAL::isa( $self->{requests_session}, "LWP::UserAgent" ) ) { + $self->{_session} = $self->{requests_session}; + } + elsif ( $self->{requests_session} ) { + $self->{_session} = LWP::UserAgent->new; + } + + $self->{cookies} = "SOCS=CAI"; + + $self->{context} = initialize_context(); + $self->{context}{"context"}{"client"}{"hl"} = "en"; + + $self->{parser} = YTMusicAPI::Parsers::Parser->new(); + + if ( $self->{user} ) { + $self->{context}{'context'}{'user'}{'onBehalfOfUser'} = $self->{user}; + } + + $self->{params} = YTM_PARAMS; + if ( $self->{auth_type} == AuthType::BROWSER ) { + $self->{params} .= YTM_PARAMS_KEY; + } + + return $self; +} + +sub base_headers { + my ($self) = @_; + + if ( !$self->{_base_headers} ) { + if ( $self->{auth_type} == AuthType::BROWSER + or $self->{auth_type} == AuthType::OAUTH_CUSTOM_FULL ) + { + $self->{_base_headers} = $self->{_input_dict}; + } + else { + $self->{_base_headers} = { + "user-agent" => USER_AGENT, + "accept" => "*/*", + "accept-encoding" => "gzip, deflate", + "content-type" => "application/json", + "content-encoding" => "gzip", + "origin" => YTM_DOMAIN, + }; + } + } + + return $self->{_base_headers}; +} + +sub headers { + my ($self) = @_; + + if ( !$self->{_headers} ) { + $self->{_headers} = $self->base_headers(); + } + + if ( $self->{auth_type} == AuthType::BROWSER ) { + $self->{_headers}{'authorization'} = + get_authorization( $self->{sapisid} . ' ' . $self->{origin} ); + } + elsif ( $self->{auth_type} != AuthType::OAUTH_CUSTOM_FULL ) { + + # $self->{_headers}{'authorization'} = $self->{_token}->as_auth(); + $self->{_headers}{'X-Goog-Request-Time'} = '' . time(); + } + + return $self->{_headers}; +} + +sub _send_request { + my ( $self, $url, $body, $additionalParams ) = @_; + $additionalParams //= ""; + + @$body{ keys %{ $self->{context} } } = values %{ $self->{context} }; + + 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; + } + + $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} ); + } + + $request->content( encode_json($body) ); + my $response = $self->{_session}->request($request); + + if ( !$response->is_success ) { + my $message = "Server returned HTTP " . $response->code . ".\n"; + my $error = $response->message; + die $message . $error; + } + + return decode_json( $response->decoded_content ); +} + +sub _send_get_request { + my ( $self, $url, $params ) = @_; + + my $response = + $self->{_session} + ->get( $url, $self->{_headers} ? $self->headers() : $self->base_headers(), + ); + + return $response; +} + +sub _check_auth { + my ($self) = @_; + unless ( $self->{auth} ) { + die "Please provide authentication before using this function"; + } +} + +1;