diff --git a/Tests/Test.pl b/Tests/Test.pl index bb6e53d..a3b7983 100644 --- a/Tests/Test.pl +++ b/Tests/Test.pl @@ -6,16 +6,27 @@ use warnings; use Data::Dumper; use YTMusicAPI::YTMusic; +use YTMusicAPI::Auth::OAuth::OAuthCredentials; -my $yt = YTMusicAPI::YTMusic->new(); +# my $c = YTMusicAPI::Auth::OAuth::OAuthCredentials->new(); + +# my $code = $c->get_code(); + +# print Dumper($code); + +my $yt = YTMusicAPI::YTMusic->new('Tests/oauth.json'); # 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"} } ) { +my $liked_songs = $yt->get_liked_songs(2); + +foreach my $track ( @{ $liked_songs->{"tracks"} } ) { print $track->{"title"} . "\n"; } +# my $song = $yt->get_song("CF1rt_9pdgU"); +# print Dumper($song); + 1; diff --git a/YTMusicAPI/Auth/OAuth/OAuthCredentials.pm b/YTMusicAPI/Auth/OAuth/OAuthCredentials.pm new file mode 100644 index 0000000..beadeb8 --- /dev/null +++ b/YTMusicAPI/Auth/OAuth/OAuthCredentials.pm @@ -0,0 +1,109 @@ +package YTMusicAPI::Auth::OAuth::OAuthCredentials; + +use strict; +use warnings; + +use JSON; +use LWP::UserAgent; +use Data::Dumper; + +use YTMusicAPI::Constants qw( + OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET + OAUTH_CODE_URL + OAUTH_SCOPE + OAUTH_TOKEN_URL + OAUTH_USER_AGENT +); + +sub new { + my ( $class, $args ) = @_; + + unless ( defined $args->{client_id} == defined $args->{client_secret} ) { + die "OAuthCredential init failure. " + . "Provide both client_id and client_secret or neither."; + } + + my $self = {}; + bless $self, $class; + + $self->{client_id} = + $args->{client_id} ? $args->{client_id} : OAUTH_CLIENT_ID; + $self->{client_secret} = + $args->{client_secret} ? $args->{client_secret} : OAUTH_CLIENT_SECRET; + + $self->{_session} = + $args->{session} ? $args->{session} : LWP::UserAgent->new; + + if ( $args->{proxies} ) { + @$self->{_session}{ keys %{ $args->{proxies} } } = + values %{ $args->{proxies} }; + } + + return $self; +} + +sub get_code { + my ($self) = @_; + my $code_response = + $self->_send_request( OAUTH_CODE_URL, { "scope" => OAUTH_SCOPE } ); + return decode_json( $code_response->decoded_content ); +} + +sub _send_request { + my ( $self, $url, $data ) = @_; + + $data->{"client_id"} = $self->{client_id}; + my $request = HTTP::Request->new( POST => $url ); + $request->header( "User-Agent" => OAUTH_USER_AGENT ); + $request->content( encode_json($data) ); + my $response = $self->{_session}->request($request); + + if ( $response->code == 401 ) { + my $data = $response->decode_json; + my $issue = $data->{"error"}; + if ( $issue eq "unauthorized_client" ) { + die "Token refresh error. Most likely client/token mismatch."; + } + elsif ( $issue eq "invalid_client" ) { + die "OAuth client failure. Most likely client_id and client_secret " + . "mismatch or YouTubeData API is not enabled."; + } + else { + die "OAuth request error. status_code: " + . $response->code + . ", url: $url, content: " + . Dumper($data); + } + } + + return $response; +} + +sub token_from_code { + my ( $self, $device_code ) = @_; + my $response = $self->_send_request( + OAUTH_TOKEN_URL, + { + "client_secret" => $self->{client_secret}, + "grant_type" => "http://oauth.net/grant_type/device/1.0", + "code" => $device_code, + } + ); + return decode_json( $response->decoded_content ); +} + +sub refresh_token { + my ( $self, $refresh_token ) = @_; + my $response = $self->_send_request( + OAUTH_TOKEN_URL, + { + "client_secret" => $self->{client_secret}, + "grant_type" => "refresh_token", + "refresh_token" => $refresh_token, + } + ); + return decode_json( $response->decoded_content ); +} + +1; diff --git a/YTMusicAPI/Auth/OAuth/OAuthToken.pm b/YTMusicAPI/Auth/OAuth/OAuthToken.pm new file mode 100644 index 0000000..8d0593c --- /dev/null +++ b/YTMusicAPI/Auth/OAuth/OAuthToken.pm @@ -0,0 +1,51 @@ +package YTMusicAPI::Auth::OAuth::OAuthToken; + +use strict; +use warnings; + +use JSON qw(decode_json); +use Path::Tiny qw(path); + +use parent 'YTMusicAPI::Auth::OAuth::Token'; + +sub new { + my ( $class, $args ) = @_; + my $self = $class->SUPER::new($args); + bless $self, $class; + return $self; +} + +sub is_oauth { + my ($headers) = @_; + my @required_keys = YTMusicAPI::Auth::OAuth::Token::members(); + + foreach my $key (@required_keys) { + return 0 unless exists $headers->{$key}; + } + + return 1; +} + +sub update { + my ( $self, $fresh_access ) = @_; + $self->{access_token} = $fresh_access->{access_token}; + $self->{expires_at} = int( time() ) + $fresh_access->{expires_in}; +} + +sub is_expiring { + my ($self) = @_; + return $self->{expires_at} - int( time() ) < 60 ? 1 : 0; +} + +sub from_json { + my ( $class, $file_path ) = @_; + my $file_pack; + + if ( path($file_path)->is_file ) { + $file_pack = decode_json( path($file_path)->slurp_utf8 ); + } + + return $class->new(%$file_pack); +} + +1; diff --git a/YTMusicAPI/Auth/OAuth/RefreshingToken.pm b/YTMusicAPI/Auth/OAuth/RefreshingToken.pm new file mode 100644 index 0000000..ed99736 --- /dev/null +++ b/YTMusicAPI/Auth/OAuth/RefreshingToken.pm @@ -0,0 +1,80 @@ +package YTMusicAPI::Auth::OAuth::RefreshingToken; + +use strict; +use warnings; + +use parent 'YTMusicAPI::Auth::OAuth::OAuthToken'; +use JSON qw(encode_json); +use Path::Tiny qw(path); + +sub new { + my ( $class, $credentials, $local_cache, $args ) = @_; + my $self = $class->SUPER::new($args); + $self->{credentials} = $credentials; + $self->{_local_cache} = $local_cache; + return $self; +} + +sub get_attribute { + my ( $self, $item ) = @_; + if ( $item eq "access_token" && $self->is_expiring ) { + my $fresh = + $self->{credentials}->refresh_token( $self->{refresh_token} ); + $self->update($fresh); + $self->store_token(); + } + return $self->{$item}; +} + +sub local_cache { + my $self = shift; + if (@_) { + $self->{_local_cache} = shift; + $self->store_token(); + } + return $self->{_local_cache}; +} + +sub prompt_for_token { + my ( $class, $credentials, $open_browser, $to_file ) = @_; + $open_browser //= 0; + + my $code = $credentials->get_code(); + my $url = "$code->{verification_url}?user_code=$code->{user_code}"; + if ($open_browser) { + if ( $^O eq 'MSWin32' ) { + system("start $url"); + } + elsif ( $^O eq 'darwin' ) { + system("open $url"); + } + elsif ( $^O eq 'linux' ) { + system("xdg-open $url"); + } + else { + warn "Unable to open url. Unsupported OS"; + } + } + print +"Go to $url, finish the login flow and press Enter when done, Ctrl-C to abort\n"; + ; + my $raw_token = $credentials->token_from_code( $code->{device_code} ); + + my $ref_token = $class->new( %$raw_token, credentials => $credentials ); + $ref_token->update( $ref_token->as_dict() ); + if ($to_file) { + $ref_token->local_cache($to_file); + } + return $ref_token; +} + +sub store_token { + my ( $self, $path ) = @_; + $path //= $self->local_cache(); + + if ($path) { + path($path)->spew_utf8( encode_json( $self->as_dict() ) ); + } +} + +1; diff --git a/YTMusicAPI/Auth/OAuth/Token.pm b/YTMusicAPI/Auth/OAuth/Token.pm new file mode 100644 index 0000000..96ac8ee --- /dev/null +++ b/YTMusicAPI/Auth/OAuth/Token.pm @@ -0,0 +1,61 @@ +package YTMusicAPI::Auth::OAuth::Token; + +use strict; +use warnings; + +use JSON; + +sub new { + my ( $class, $args ) = @_; + my $self = { + scope => $args->{scope} // "https://www.googleapis.com/auth/youtube", + token_type => $args->{token_type} // "Bearer", + access_token => $args->{access_token} // undef, + refresh_token => $args->{refresh_token} // undef, + expires_at => $args->{expires_at} // 0, + expires_in => $args->{expires_in} // 0, + }; + bless $self, $class; + return $self; +} + +sub members { + return qw( + scope + token_type + access_token + refresh_token + expires_at + expires_in + ); +} + +sub repr { + my ($self) = @_; + return ref($self) . ": " . $self->as_json(); +} + +sub as_dict { + my ($self) = @_; + my %copy; + @copy{ $self->members() } = @{$self}{ members() }; + return \%copy; +} + +sub as_json { + my ($self) = @_; + my $dict = $self->as_dict(); + return encode_json($dict); +} + +sub as_auth { + my ($self) = @_; + return $self->{token_type} . " " . $self->{access_token}; +} + +sub is_expiring { + my ($self) = @_; + return $self->{expires_in} < 60 ? 1 : 0; +} + +1; diff --git a/YTMusicAPI/YTMusic.pm b/YTMusicAPI/YTMusic.pm index c969760..7860c08 100644 --- a/YTMusicAPI/YTMusic.pm +++ b/YTMusicAPI/YTMusic.pm @@ -14,7 +14,8 @@ use JSON; use LWP::UserAgent; use HTTP::Cookies; use URI::Escape; -use Encode qw(encode_utf8); +use Encode qw(encode_utf8); +use Path::Tiny qw(path); use Data::Dumper; use YTMusicAPI::Constants qw( @@ -30,45 +31,104 @@ use YTMusicAPI::Constants qw( use YTMusicAPI::Helpers; use YTMusicAPI::Parsers::Parser; use YTMusicAPI::Auth::AuthTypes; +use YTMusicAPI::Auth::OAuth::OAuthCredentials; +use YTMusicAPI::Auth::OAuth::OAuthToken; +use YTMusicAPI::Auth::OAuth::RefreshingToken; 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, - }; + my ( $class, $auth, $user, $requests_session, $proxies, $language, + $location, $oauth_credentials ) + = @_; + $requests_session //= 1; + $language //= 'en'; + $location //= ''; + + my $self = {}; bless $self, $class; + $self->{_base_headers} = + undef; #: for authless initializing requests during OAuth flow + $self->{_headers} = undef; #: cache formed headers including auth + + $self->{auth} = $auth; #: raw auth + $self->{_input_dict} = undef; #: parsed auth arg value in dictionary format + $self->{auth_type} = AuthType::UNAUTHORIZED; - if ( UNIVERSAL::isa( $self->{requests_session}, "LWP::UserAgent" ) ) { - $self->{_session} = $self->{requests_session}; + $self->{_token} = undef; #: OAuth credential handler + $self->{oauth_credentials} = undef; #: Client used for OAuth refreshing + + $self->{_session} = undef; #: request session for connection pooling + $self->{proxies} = $proxies; #: params for session modification + + if ( UNIVERSAL::isa( $requests_session, "LWP::UserAgent" ) ) { + $self->{_session} = $requests_session; } - elsif ( $self->{requests_session} ) { + elsif ($requests_session) { # Build a new session. $self->{_session} = LWP::UserAgent->new; } +# see google cookie docs: https://policies.google.com/technologies/cookies +# value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 $self->{cookies} = "SOCS=CAI"; + if ( $self->{auth} ) { + $self->{oauth_credentials} = + $oauth_credentials + ? $oauth_credentials + : YTMusicAPI::Auth::OAuth::OAuthCredentials->new(); + my $auth_filepath = undef; + if ( !ref $self->{auth} ) { + my $auth_str = $self->{auth}; + my $input_json; + if ( path($auth_str)->is_file() ) { + open( my $fh, '<', $auth_str ); + my $json_file = do { local $/; <$fh> }; + close($fh); + $auth_filepath = $auth_str; + $input_json = decode_json($json_file); + } + else { + $input_json = decode_json($auth_str); + } + $self->{_input_dict} = $input_json; + } + else { + $self->{_input_dict} = $self->{auth}; + } + + if ( + YTMusicAPI::Auth::OAuth::OAuthToken::is_oauth( + $self->{_input_dict} + ) + ) + { + $self->{_token} = YTMusicAPI::Auth::OAuth::RefreshingToken->new( + $self->{oauth_credentials}, + $auth_filepath, $self->{_input_dict} ); + $self->{auth_type} = + $oauth_credentials + ? AuthType::OAUTH_CUSTOM_CLIENT + : AuthType::OAUTH_DEFAULT; + } + } + + # prepare context $self->{context} = initialize_context(); + $self->{context}{"context"}{"client"}{"hl"} = "en"; + $self->{language} = $language; $self->{parser} = YTMusicAPI::Parsers::Parser->new(); - if ( $self->{user} ) { - $self->{context}{'context'}{'user'}{'onBehalfOfUser'} = $self->{user}; + if ($user) { + $self->{context}{'context'}{'user'}{'onBehalfOfUser'} = $user; + } + + my $auth_headers = $self->{_input_dict}->{"authorization"}; + if ($auth_headers) { + } $self->{params} = YTM_PARAMS; @@ -115,8 +175,7 @@ sub headers { get_authorization( $self->{sapisid} . ' ' . $self->{origin} ); } elsif ( $self->{auth_type} != AuthType::OAUTH_CUSTOM_FULL ) { - - # $self->{_headers}{'authorization'} = $self->{_token}->as_auth(); + $self->{_headers}{'authorization'} = $self->{_token}->as_auth(); $self->{_headers}{'X-Goog-Request-Time'} = '' . time(); }