diff --git a/HTML/EN/plugins/SqueezeCloud/settings/basic.html b/HTML/EN/plugins/SqueezeCloud/settings/basic.html index 551d718..748e07c 100644 --- a/HTML/EN/plugins/SqueezeCloud/settings/basic.html +++ b/HTML/EN/plugins/SqueezeCloud/settings/basic.html @@ -1,30 +1,51 @@ [% PROCESS settings/header.html %] + [% IF code %] + [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD" desc="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD_DESC" %] + [% "PLUGIN_SQUEEZECLOUD_LOGGING_IN" | string %] + [% END %] + [% ELSIF username %] + [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD" desc="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD_DESC" %] + [% "PLUGIN_SQUEEZECLOUD_LOGGED_IN_AS" | string %] [% username %]
+ [% "PLUGIN_SQUEEZECLOUD_LOG_OUT" | string %] + [% END %] + [% ELSIF codeChallenge %] [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD" desc="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD_DESC" %] - - Connect with SoundCloud - + Connect with SoundCloud [% END %] - [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_APIKEY" desc="PLUGIN_SQUEEZECLOUD_APIKEY_DESC" %] + [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_CODE" desc="PLUGIN_SQUEEZECLOUD_CODE_DESC" %]
- +
+ [% END %] + [% ELSE %] + [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD" desc="PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD_DESC" %] + [% "PLUGIN_SQUEEZECLOUD_LOGIN_ERROR" | string %] + [% END %] + [% END %] [% WRAPPER setting title="PLUGIN_SQUEEZECLOUD_PLAYMETHOD" desc="PLUGIN_SQUEEZECLOUD_PLAYMETHOD_DESC" %]
diff --git a/Oauth2.pm b/Oauth2.pm new file mode 100644 index 0000000..c6d1a41 --- /dev/null +++ b/Oauth2.pm @@ -0,0 +1,202 @@ +package Plugins::SqueezeCloud::Oauth2; + +# Plugin to stream audio from SoundCloud streams +# +# Released under GNU General Public License version 2 (GPLv2) +# +# Written by Daniel Vijge +# +# See file LICENSE for full license details + +use strict; + +use Slim::Utils::Prefs; +use Slim::Utils::Log; +use Slim::Utils::Cache; +use Slim::Utils::Strings qw(string cstring); +use JSON::XS::VersionOneAndTwo; +use Plugins::SqueezeCloud::Random qw(random_regex); +use Digest::SHA qw(sha256_base64); + +my $log = logger('plugin.squeezecloud'); +my $prefs = preferences('plugin.squeezecloud'); +my $cache = Slim::Utils::Cache->new(); + +use constant CLIENT_ID => "112d35211af80d72c8ff470ab66400d8"; +use constant CLIENT_SECRET => "fc63200fee37d02bc3216cfeffe5f5ae"; +use constant REDIRECT_URI => "https%3A%2F%2Fdanielvijge.github.io%2FSqueezeCloud%2Fcallback.html"; +use constant META_CACHE_TTL => 86400 * 30; # 24 hours x 30 = 30 days + +sub isLoggedIn { + return(isRefreshTokenAvailable() || isApiKeyAvailable()); +} + +sub isApiKeyAvailable { + return ($prefs->get('apiKey') ne ''); +} + +sub isAccessTokenAvailable { + return ($cache->get('access_token') ne ''); +} + +sub isRefreshTokenAvailable { + return ($cache->get('refresh_token') ne ''); +} + +sub isAccessTokenExpired { + return 0 if isApiKeyAvailable(); # API key cannot expire + return 0 if isAccessTokenAvailable(); # Access token still valid + return 1 if isRefreshTokenAvailable(); # Access token expired, refresh token available + return 1; # This should not happen, equal to isLoggedIn() == false +} + +sub getAccessToken { + $log->debug('getAccessToken started.'); + + if (!isRefreshTokenAvailable()) { + $log->error('No authentication available. Use the settings page to log in first.'); + return; + } + + if (!isAccessTokenAvailable()) { + $log->info('Access token has expired. Getting a new access token with the refresh token.'); + getAccessTokenWithRefreshToken(\&getAccessToken, @_); + return; + } + + $log->debug('Cached access token ' . $cache->get('access_token')); + return $cache->get('access_token'); +} + +sub getAuthorizationToken { + $log->debug('getAuthorizationToken started.'); + + my $code = shift; + + if (!$cache->get('codeVerifier')) { + $log->error('No code verifier is available. Reload the page and try to authenticate again.'); + return; + } + + my $post = "grant_type=authorization_code" . + "&client_id=" . CLIENT_ID . + "&client_secret=" . CLIENT_SECRET . + "&redirect_uri=" . REDIRECT_URI . + "&code_verifier=" . $cache->get('codeVerifier') . + "&code=" . $code; + + my $http = Slim::Networking::SimpleAsyncHTTP->new( + sub { + $log->debug('Successful request for authorization_code.'); + my $response = shift; + my $result = eval { from_json($response->content) }; + + $cache->set('access_token', $result->{access_token}, 30); + $cache->set('refresh_token', $result->{refresh_token}, META_CACHE_TTL); + }, + sub { + $log->error('Failed request for authorization_code.'); + $log->error($_[1]); + + my $response = shift; + my $result = eval { from_json($response->content) }; + $log->error($result); + }, + { + timeout => 15, + } + ); + $log->debug($post); + $http->post( + "https://secure.soundcloud.com/oauth/token", + 'Content-Type' => 'application/x-www-form-urlencoded', + $post, + ); +} + +sub getAccessTokenWithRefreshToken { + $log->debug('getAccessTokenWithRefreshToken started.'); + + my $cb = shift; + my @params = @_; + + if (!isRefreshTokenAvailable()) { + $log->error('No authentication available. Use the settings page to log in first.'); + return; + } + + if (isAccessTokenAvailable()) { + $log->debug('Still an access token available. No need for a refresh.'); + return; + } + + $log->debug('Cached refresh token ' . $cache->get('refresh_token')); + my $post = "grant_type=refresh_token" . + "&client_id=" . CLIENT_ID . + "&client_secret=" . CLIENT_SECRET . + "&refresh_token=" . $cache->get('refresh_token'); + + my $http = Slim::Networking::SimpleAsyncHTTP->new( + sub { + $log->debug('Successful request for refresh_token'); + my $response = shift; + my $result = eval { from_json($response->content) }; + $cache->set('access_token', $result->{access_token}, 30); + $cache->set('refresh_token', $result->{refresh_token}, META_CACHE_TTL); + $cb->(@params) if $cb; + }, + sub { + $log->error('Failed request for refresh_token'); + $log->error($_[1]); + $log->debug('Removing refresh_token for failed request. User is nog logged out.'); + $cache->remove('refresh_token'); + $cb->(@params) if $cb; + }, + { + timeout => 15, + } + ); + $log->debug($post); + $http->post( + "https://secure.soundcloud.com/oauth/token", + 'Content-Type' => 'application/x-www-form-urlencoded', + $post, + ); +} + +sub getAuthenticationHeaders { + $log->debug('getAuthenticationHeaders started.'); + if (isApiKeyAvailable()) { + # If there is still an older API key, use this for authentication + $log->debug('Using old API key ' . $prefs->get('apiKey')); + return 'Authorization' => 'OAuth ' . $prefs->get('apiKey'); + } + else { + $log->debug('Using bearer token for authorization'); + return 'Authorization' => 'Bearer ' . getAccessToken(); + } +} + +sub getCodeChallenge { + $log->debug('getCodeChallenge started.'); + if ($cache->get('codeChallenge')) { + $log->debug('Random string [cached]: '. $cache->get('codeVerifier')); + $log->debug('S256 [cached]: '.$cache->get('codeChallenge')); + return $cache->get('codeChallenge'); + } + + my $randomString = random_regex('[a-z0-9]{56}'); + my $s256 = sha256_base64($randomString); + $s256 =~ s/\+/-/g; + $s256 =~ s/\//_/g; + $s256 =~ s/=$//g; + + $log->debug('Random string: '.$randomString); + $log->debug('S256: '.$s256); + + $cache->set('codeVerifier', $randomString, 60); + $cache->set('codeChallenge', $s256, 60); + return $s256; +} + +1; diff --git a/Plugin.pm b/Plugin.pm index ac82eb1..7356636 100644 --- a/Plugin.pm +++ b/Plugin.pm @@ -29,7 +29,7 @@ use POSIX qw(strftime); use Slim::Utils::Strings qw(string cstring); use Slim::Utils::Prefs; use Slim::Utils::Log; - +use Plugins::SqueezeCloud::Oauth2; # Defines the timeout in seconds for a http request use constant HTTP_TIMEOUT => 15; @@ -154,11 +154,6 @@ sub defaultMeta { $log->debug('defaultMeta ended.'); } -sub getAuthenticationHeaders() { - $log->debug('getAuthenticationHeaders started.'); - return 'Authorization' => 'OAuth ' . $prefs->get('apiKey'); -} - # Extracts the available metadata for a tracks from the JSON data. The data # is cached and then returned to be presented to the user. sub _makeMetadata { @@ -346,9 +341,14 @@ sub likeTrack { my $url = $method . "://api.soundcloud.com/likes/tracks/$id"; $log->debug("Liking: $url"); + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&likeTrack, @_); + return; + } + my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::POST($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::POST($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -376,10 +376,14 @@ sub unlikeTrack { my $url = $method . "://api.soundcloud.com/likes/tracks/$id"; $log->debug("Unliking: $url"); - + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&unlikeTrack, @_); + return; + } + my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::DELETE($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::DELETE($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -451,7 +455,7 @@ sub _gotMetadata { requests_redirectable => [], ); - my $res = $ua->get( getStreamURL($json), getAuthenticationHeaders() ); + my $res = $ua->get( getStreamURL($json), Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders() ); my $stream = $res->header( 'location' ); @@ -487,6 +491,11 @@ sub fetchMetadata { my $extras = "linked_partitioning=true&limit=1"; my $queryUrl = $method."://api.soundcloud.com/".$resource."?" . $extras . $params; + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&fetchMetadata, @_); + return; + } + # Call the server to fetch the data via the asynchronous http request. # The methods are called when a response was received or an error # occurred. Additional information to the http call is passed via @@ -501,7 +510,7 @@ sub fetchMetadata { }, ); - $http->get($queryUrl, getAuthenticationHeaders()); + $http->get($queryUrl, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); } $log->debug('fetchMetadata ended.'); @@ -652,6 +661,11 @@ sub _getTracks { $log->debug('_getTracks started.'); my ($client, $searchType, $index, $quantity, $queryUrl, $uid, $cursor, $parser, $callback, $menu) = @_; + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&_getTracks, @_); + return; + } + Slim::Networking::SimpleAsyncHTTP->new( # Called when a response has been received for the request. sub { @@ -707,7 +721,7 @@ sub _getTracks { $callback->([ { name => $_[1], type => 'text' } ]); }, - )->get($queryUrl, getAuthenticationHeaders()); + )->get($queryUrl, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); $log->debug('_getTracks ended.'); } @@ -788,6 +802,11 @@ sub urlHandler { my $queryUrl = "https://api.soundcloud.com/resolve?url=$url"; $log->debug("fetching: $queryUrl"); + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&urlHandler, @_); + return; + } + my $fetch = sub { Slim::Networking::SimpleAsyncHTTP->new( sub { @@ -806,7 +825,7 @@ sub urlHandler { $log->warn("error: $_[1]"); $callback->([ { name => $_[1], type => 'text' } ]); }, - )->get($queryUrl, getAuthenticationHeaders()); + )->get($queryUrl, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); }; $fetch->(); @@ -867,9 +886,14 @@ sub likePlaylist { my $url = $method . "://api.soundcloud.com/likes/playlists/$id"; $log->debug("Liking: $url"); + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&likePlaylist, @_); + return; + } + my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::POST($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::POST($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -897,10 +921,14 @@ sub unlikePlaylist { my $url = $method . "://api.soundcloud.com/likes/playlists/$id"; $log->debug("Unliking: $url"); + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&unlikePlaylist, @_); + return; + } my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::DELETE($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::DELETE($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -1070,9 +1098,14 @@ sub followFriend { my $url = $method . "://api.soundcloud.com/me/followings/$uid"; $log->debug("Following: $url"); + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&followFriend, @_); + return; + } + my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::POST($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::POST($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -1099,10 +1132,14 @@ sub unfollowFriend { my $url = $method . "://api.soundcloud.com/me/followings/$uid"; $log->debug("Unfollowing: $url"); - + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&unfollowFriend, @_); + return; + } + my $fetch = sub { my $ua = LWP::UserAgent->new; - my $request = HTTP::Request::Common::DELETE($url, getAuthenticationHeaders()); + my $request = HTTP::Request::Common::DELETE($url, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); my $response = $ua->request($request); if ( $response->is_success() ) { @@ -1249,7 +1286,7 @@ sub playerMenu { shift->can('nonSNApps') ? undef : 'RADIO' } sub toplevel { $log->debug('toplevel started.'); my ($client, $callback, $args) = @_; - + # These are the available main menus. The variable type defines the menu # type (search allows text input, link opens another menu), the url defines # the method that shall be called when the user has selected the menu entry. @@ -1257,8 +1294,8 @@ sub toplevel { # method defined by the url variable. my $callbacks = []; - # Add the following menu items only when the user has specified an API key - if ($prefs->get('apiKey') ne '') { + # Add the following menu items only when the user is logged in + if (Plugins::SqueezeCloud::Oauth2::isLoggedIn()) { # Menu entry to show all activities (Stream) push(@$callbacks, @@ -1336,9 +1373,10 @@ sub toplevel { push(@$callbacks, { name => string('PLUGIN_SQUEEZECLOUD_URL'), type => 'search', url => \&urlHandler, } ); + } else { push(@$callbacks, - { name => string('PLUGIN_SQUEEZECLOUD_SET_API_KEY'), type => 'text' } + { name => string('PLUGIN_SQUEEZECLOUD_LOGIN'), type => 'text' } ); } diff --git a/ProtocolHandler.pm b/ProtocolHandler.pm index fb1f9da..75a13e5 100644 --- a/ProtocolHandler.pm +++ b/ProtocolHandler.pm @@ -28,6 +28,7 @@ use Slim::Utils::Prefs; use Slim::Utils::Errno; use Slim::Utils::Cache; use Scalar::Util qw(blessed); +use Plugins::SqueezeCloud::Oauth2; my $log = logger('plugin.squeezecloud'); @@ -55,10 +56,6 @@ my $prefix = 'sc:'; sub canSeek { 0 } -sub getAuthenticationHeaders() { - return 'Authorization' => 'OAuth ' . $prefs->get('apiKey'); -} - sub _makeMetadata { my ($json) = shift; @@ -154,7 +151,7 @@ sub gotNextTrack { requests_redirectable => [], ); - my $res = $ua->get($stream, getAuthenticationHeaders() ); + my $res = $ua->get($stream, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders() ); my $redirector = $res->header( 'location' ); @@ -195,6 +192,11 @@ sub getNextTrack { # Talk to SN and get the next track to play my $trackURL = "https://api.soundcloud.com/tracks/" . $id; + + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&getNextTrack, @_); + return; + } my $http = Slim::Networking::SimpleAsyncHTTP->new( \&gotNextTrack, @@ -210,7 +212,7 @@ sub getNextTrack { main::DEBUGLOG && $log->is_debug && $log->debug("Getting track from soundcloud for $id"); - $http->get( $trackURL, getAuthenticationHeaders() ); + $http->get( $trackURL, Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders() ); } # To support remote streaming (synced players, slimp3/SB1), we need to subclass Protocols::HTTP diff --git a/Random.pm b/Random.pm new file mode 100644 index 0000000..a30b2f8 --- /dev/null +++ b/Random.pm @@ -0,0 +1,559 @@ +# String::Random - Generates a random string from a pattern +# Copyright (C) 1999-2006 Steven Pritchard +# +# This program is free software; you can redistribute it +# and/or modify it under the same terms as Perl itself. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# $Id: Random.pm,v 1.4 2006/09/21 17:34:07 steve Exp $ + +package Plugins::SqueezeCloud::Random; + +require 5.006_001; + +use strict; +use warnings; + +use Carp; +use parent qw(Exporter); + +our %EXPORT_TAGS = ( + 'all' => [ + qw( + &random_string + &random_regex + ) + ] +); +our @EXPORT_OK = ( @{ $EXPORT_TAGS{'all'} } ); + +# These are the various character sets. +my @upper = ( 'A' .. 'Z' ); +my @lower = ( 'a' .. 'z' ); +my @digit = ( '0' .. '9' ); +my @punct = map {chr} ( 33 .. 47, 58 .. 64, 91 .. 96, 123 .. 126 ); +my @any = ( @upper, @lower, @digit, @punct ); +my @salt = ( @upper, @lower, @digit, '.', '/' ); +my @binary = map {chr} ( 0 .. 255 ); + +# What's important is how they relate to the pattern characters. +# These are the old patterns for randpattern/random_string. +my %old_patterns = ( + 'C' => [@upper], + 'c' => [@lower], + 'n' => [@digit], + '!' => [@punct], + '.' => [@any], + 's' => [@salt], + 'b' => [@binary], +); + +# These are the regex-based patterns. +my %patterns = ( + + # These are the regex-equivalents. + '.' => [@any], + '\d' => [@digit], + '\D' => [ @upper, @lower, @punct ], + '\w' => [ @upper, @lower, @digit, '_' ], + '\W' => [ grep { $_ ne '_' } @punct ], + '\s' => [ q{ }, "\t" ], # Would anything else make sense? + '\S' => [ @upper, @lower, @digit, @punct ], + + # These are translated to their double quoted equivalents. + '\t' => ["\t"], + '\n' => ["\n"], + '\r' => ["\r"], + '\f' => ["\f"], + '\a' => ["\a"], + '\e' => ["\e"], +); + +# This is used for cache of parsed range patterns in %regch +my %parsed_range_patterns = (); + +# These characters are treated specially in randregex(). +my %regch = ( + '\\' => sub { + my ( $self, $ch, $chars, $string ) = @_; + if ( @{$chars} ) { + my $tmp = shift( @{$chars} ); + if ( $tmp eq 'x' ) { + + # This is supposed to be a number in hex, so + # there had better be at least 2 characters left. + $tmp = shift( @{$chars} ) . shift( @{$chars} ); + push( @{$string}, [ chr( hex($tmp) ) ] ); + } + elsif ( $tmp =~ /[0-7]/ ) { + carp 'octal parsing not implemented. treating literally.'; + push( @{$string}, [$tmp] ); + } + elsif ( defined( $patterns{"\\$tmp"} ) ) { + $ch .= $tmp; + push( @{$string}, $patterns{$ch} ); + } + else { + if ( $tmp =~ /\w/ ) { + carp "'\\$tmp' being treated as literal '$tmp'"; + } + push( @{$string}, [$tmp] ); + } + } + else { + croak 'regex not terminated'; + } + }, + '.' => sub { + my ( $self, $ch, $chars, $string ) = @_; + push( @{$string}, $patterns{$ch} ); + }, + '[' => sub { + my ( $self, $ch, $chars, $string ) = @_; + my @tmp; + while ( defined( $ch = shift( @{$chars} ) ) && ( $ch ne ']' ) ) { + if ( ( $ch eq '-' ) && @{$chars} && @tmp ) { + my $begin_ch = $tmp[-1]; + $ch = shift( @{$chars} ); + my $key = "$begin_ch-$ch"; + if ( defined( $parsed_range_patterns{$key} ) ) { + push( @tmp, @{ $parsed_range_patterns{$key} } ); + } + else { + my @chs; + for my $n ( ( ord($begin_ch) + 1 ) .. ord($ch) ) { + push @chs, chr($n); + } + $parsed_range_patterns{$key} = \@chs; + push @tmp, @chs; + } + } + else { + carp "'$ch' will be treated literally inside []" + if ( $ch =~ /\W/ ); + push( @tmp, $ch ); + } + } + croak 'unmatched []' if ( $ch ne ']' ); + push( @{$string}, \@tmp ); + }, + '*' => sub { + my ( $self, $ch, $chars, $string ) = @_; + unshift( @{$chars}, split( //, '{0,}' ) ); + }, + '+' => sub { + my ( $self, $ch, $chars, $string ) = @_; + unshift( @{$chars}, split( //, '{1,}' ) ); + }, + '?' => sub { + my ( $self, $ch, $chars, $string ) = @_; + unshift( @{$chars}, split( //, '{0,1}' ) ); + }, + '{' => sub { + my ( $self, $ch, $chars, $string ) = @_; + my $closed; + CLOSED: + for my $c ( @{$chars} ) { + if ( $c eq '}' ) { + $closed = 1; + last CLOSED; + } + } + if ($closed) { + my $tmp; + while ( defined( $ch = shift( @{$chars} ) ) && ( $ch ne '}' ) ) { + croak "'$ch' inside {} not supported" if ( $ch !~ /[\d,]/ ); + $tmp .= $ch; + } + if ( $tmp =~ /,/ ) { + if ( my ( $min, $max ) = $tmp =~ /^(\d*),(\d*)$/ ) { + if ( !length($min) ) { $min = 0 } + if ( !length($max) ) { $max = $self->{'_max'} } + croak "bad range {$tmp}" if ( $min > $max ); + if ( $min == $max ) { + $tmp = $min; + } + else { + $tmp = $min + $self->{'_rand'}( $max - $min + 1 ); + } + } + else { + croak "malformed range {$tmp}"; + } + } + if ($tmp) { + my $prev_ch = $string->[-1]; + + push @{$string}, ( ($prev_ch) x ( $tmp - 1 ) ); + } + else { + pop( @{$string} ); + } + } + else { + # { isn't closed, so treat it literally. + push( @{$string}, [$ch] ); + } + }, +); + +# Default rand function +sub _rand { + my ($max) = @_; + return int rand $max; +} + +sub new { + my ( $proto, @args ) = @_; + my $class = ref($proto) || $proto; + my $self; + $self = {%old_patterns}; # makes $self refer to a copy of %old_patterns + my %args = (); + if (@args) { %args = @args } + if ( defined( $args{'max'} ) ) { + $self->{'_max'} = $args{'max'}; + } + else { + $self->{'_max'} = 10; + } + if ( defined( $args{'rand_gen'} ) ) { + $self->{'_rand'} = $args{'rand_gen'}; + } + else { + $self->{'_rand'} = \&_rand; + } + return bless( $self, $class ); +} + +# Returns a random string for each regular expression given as an +# argument, or the strings concatenated when used in a scalar context. +sub randregex { + my $self = shift; + croak 'called without a reference' if ( !ref($self) ); + + my @strings = (); + + while ( defined( my $pattern = shift ) ) { + my $ch; + my @string = (); + my $string = q{}; + + # Split the characters in the pattern + # up into a list for easier parsing. + my @chars = split( //, $pattern ); + + while ( defined( $ch = shift(@chars) ) ) { + if ( defined( $regch{$ch} ) ) { + $regch{$ch}->( $self, $ch, \@chars, \@string ); + } + elsif ( $ch =~ /[\$\^\*\(\)\+\{\}\]\|\?]/ ) { + + # At least some of these probably should have special meaning. + carp "'$ch' not implemented. treating literally."; + push( @string, [$ch] ); + } + else { + push( @string, [$ch] ); + } + } + + foreach my $ch (@string) { + $string .= $ch->[ $self->{'_rand'}( scalar( @{$ch} ) ) ]; + } + + push( @strings, $string ); + } + + return wantarray ? @strings : join( q{}, @strings ); +} + +# For compatibility with an ancient version, please ignore... +sub from_pattern { + my ( $self, @args ) = @_; + croak 'called without a reference' if ( !ref($self) ); + + return $self->randpattern(@args); +} + +sub randpattern { + my $self = shift; + croak 'called without a reference' if ( !ref($self) ); + + my @strings = (); + + while ( defined( my $pattern = shift ) ) { + my $string = q{}; + + for my $ch ( split( //, $pattern ) ) { + if ( defined( $self->{$ch} ) ) { + $string .= $self->{$ch} + ->[ $self->{'_rand'}( scalar( @{ $self->{$ch} } ) ) ]; + } + else { + croak qq(Unknown pattern character "$ch"!); + } + } + push( @strings, $string ); + } + + return wantarray ? @strings : join( q{}, @strings ); +} + +sub get_pattern { + my ( $self, $name ) = @_; + return $self->{ $name }; +} + +sub set_pattern { + my ( $self, $name, $charset ) = @_; + $self->{ $name } = $charset; +} + +sub random_regex { + my (@args) = @_; + my $foo = Plugins::SqueezeCloud::Random->new; + return $foo->randregex(@args); +} + +sub random_string { + my ( $pattern, @list ) = @_; + + my $foo = Plugins::SqueezeCloud->new; + + for my $n ( 0 .. $#list ) { + $foo->{$n} = [ @{ $list[$n] } ]; + } + + return $foo->randpattern($pattern); +} + +1; +__END__ + +=encoding utf8 + +=head1 NAME + +String::Random - Perl module to generate random strings based on a pattern + +=head1 SYNOPSIS + + use String::Random; + my $string_gen = String::Random->new; + print $string_gen->randregex('\d\d\d'); # Prints 3 random digits + # Prints 3 random printable characters + print $string_gen->randpattern("..."); + +I + + use String::Random qw(random_regex random_string); + print random_regex('\d\d\d'); # Also prints 3 random digits + print random_string("..."); # Also prints 3 random printable characters + +=head1 DESCRIPTION + +This module makes it trivial to generate random strings. + +As an example, let's say you are writing a script that needs to generate a +random password for a user. The relevant code might look something like +this: + + use String::Random; + my $pass = String::Random->new; + print "Your password is ", $pass->randpattern("CCcc!ccn"), "\n"; + +This would output something like this: + + Your password is UDwp$tj5 + +B: currently, C defaults to Perl's built-in predictable +random number generator so the passwords generated by it are insecure. See the +C option to C constructor to specify a more secure +random number generator. There is no equivalent to this in the procedural +interface, you must use the object-oriented interface to get this +functionality. + +If you are more comfortable dealing with regular expressions, the following +code would have a similar result: + + use String::Random; + my $pass = String::Random->new; + print "Your password is ", + $pass->randregex('[A-Z]{2}[a-z]{2}.[a-z]{2}\d'), "\n"; + +=head2 Patterns + +The pre-defined patterns (for use with C and C) +are as follows: + + c Any Latin lowercase character [a-z] + C Any Latin uppercase character [A-Z] + n Any digit [0-9] + ! A punctuation character [~`!@$%^&*()-_+={}[]|\:;"'.<>?/#,] + . Any of the above + s A "salt" character [A-Za-z0-9./] + b Any binary data + +These can be modified, but if you need a different pattern it is better to +create another pattern, possibly using one of the pre-defined as a base. +For example, if you wanted a pattern C that contained all upper and lower +case letters (C<[A-Za-z]>), the following would work: + + my $gen = String::Random->new; + $gen->{'A'} = [ 'A'..'Z', 'a'..'z' ]; + +I + + my $gen = String::Random->new; + $gen->{'A'} = [ @{$gen->{'C'}}, @{$gen->{'c'}} ]; + +I + + my $gen = String::Random->new; + $gen->set_pattern(A => [ 'A'..'Z', 'a'..'z' ]); + +The random_string function, described below, has an alternative interface +for adding patterns. + +=head2 Methods + +=over 8 + +=item new + +=item new max =E I + +=item new rand_gen =E I + +Create a new String::Random object. + +Optionally a parameter C can be included to specify the maximum number +of characters to return for C<*> and other regular expression patterns that +do not return a fixed number of characters. + +Optionally a parameter C can be included to specify a subroutine +coderef for generating the random numbers used in this module. The coderef +must accept one argument C and return an integer between 0 and C. +The default rand_gen coderef is + + sub { + my ($max) = @_; + return int rand $max; + } + +=item randpattern LIST + +The randpattern method returns a random string based on the concatenation +of all the pattern strings in the list. + +It will return a list of random strings corresponding to the pattern +strings when used in list context. + +=item randregex LIST + +The randregex method returns a random string that will match the regular +expression passed in the list argument. + +Please note that the arguments to randregex are not real regular +expressions. Only a small subset of regular expression syntax is actually +supported. So far, the following regular expression elements are +supported: + + \w Alphanumeric + "_". + \d Digits. + \W Printable characters other than those in \w. + \D Printable characters other than those in \d. + . Printable characters. + [] Character classes. + {} Repetition. + * Same as {0,}. + ? Same as {0,1}. + + Same as {1,}. + +Regular expression support is still somewhat incomplete. Currently special +characters inside [] are not supported (with the exception of "-" to denote +ranges of characters). The parser doesn't care for spaces in the "regular +expression" either. + +=item get_pattern STRING + +Return a pattern given a name. + + my $gen = String::Random->new; + $gen->get_pattern('C'); + +(Added in version 0.32.) + +=item set_pattern STRING ARRAYREF + +Add or redefine a pattern given a name and a character set. + + my $gen = String::Random->new; + $gen->set_pattern(A => [ 'A'..'Z', 'a'..'z' ]); + +(Added in version 0.32.) + +=item from_pattern + +B - for compatibility with an old version. B + +=back + +=head2 Functions + +=over 8 + +=item random_string PATTERN,LIST + +=item random_string PATTERN + +When called with a single scalar argument, random_string returns a random +string using that scalar as a pattern. Optionally, references to lists +containing other patterns can be passed to the function. Those lists will +be used for 0 through 9 in the pattern (meaning the maximum number of lists +that can be passed is 10). For example, the following code: + + print random_string("0101", + ["a", "b", "c"], + ["d", "e", "f"]), "\n"; + +would print something like this: + + cebd + +=item random_regex REGEX_IN_STRING + +Prints a string for the regular expression given as the string. See the +synposis for example. + +=back + +=head1 BUGS + +This is Bug Free™ code. (At least until somebody finds one…) + +Please report bugs here: + +L . + +=head1 AUTHOR + +Original Author: Steven Pritchard C<< steve@silug.org >> + +Now maintained by: Shlomi Fish ( L ). + +=head1 LICENSE + +This program is free software; you can redistribute it and/or modify it +under the same terms as Perl itself. + +=head1 SEE ALSO + +perl(1). + +=cut + +# vi: set ai et: diff --git a/Settings.pm b/Settings.pm index 3e337dd..de7ae98 100644 --- a/Settings.pm +++ b/Settings.pm @@ -13,6 +13,14 @@ use strict; use base qw(Slim::Web::Settings); use Slim::Utils::Prefs; +use Slim::Utils::Log; +use Slim::Utils::Cache; + +use JSON::XS::VersionOneAndTwo; + +my $log = logger('plugin.squeezecloud'); +my $prefs = preferences('plugin.squeezecloud'); +my $cache = Slim::Utils::Cache->new(); # Returns the name of the plugin. The real # string is specified in the strings.txt file. @@ -32,5 +40,59 @@ sub prefs { return (preferences('plugin.squeezecloud'), qw(apiKey playmethod)); } +sub handler { + my ($class, $client, $params, $callback, @args) = @_; + + if ($params->{code} && $params->{code} ne '') { + $log->debug('Getting access token and refresh token from code'); + Plugins::SqueezeCloud::Oauth2::getAuthorizationToken($params->{code}); + } + elsif ($params->{logout}) { + $log->debug('Logging out...'); + $cache->remove('refresh_token'); + $cache->remove('access_token'); + $prefs->remove('apiKey'); + } + elsif (!$cache->get('refresh_token')) { + $log->debug('Generating code and code challange'); + my $codeChallenge = Plugins::SqueezeCloud::Oauth2::getCodeChallenge; + $params->{codeChallenge} = $codeChallenge; + $params->{hostName} = Slim::Utils::Misc::getLibraryName(); + } + + my $http = Slim::Networking::SimpleAsyncHTTP->new( + sub { + $log->debug('Successful request for user info.'); + my $response = shift; + my $result = eval { from_json($response->content) }; + $log->debug("User name: " . $result->{username}); + $params->{username} = $result->{username}; + + $callback->($client, $params, $class->SUPER::handler($client, $params), @args); + }, + sub { + $log->error('Failed request for user info.'); + $log->error($_[1]); + $callback->($client, $params, $class->SUPER::handler($client, $params), @args); + }, + { + timeout => 15, + } + ); + + if (Plugins::SqueezeCloud::Oauth2::isLoggedIn()) { + + if (Plugins::SqueezeCloud::Oauth2::isAccessTokenExpired()) { + Plugins::SqueezeCloud::Oauth2::getAccessTokenWithRefreshToken(\&handler, @_); + return; + } + + $http->get("https://api.soundcloud.com/me", Plugins::SqueezeCloud::Oauth2::getAuthenticationHeaders()); + } + else { + $callback->($client, $params, $class->SUPER::handler($client, $params), @args); + } +} + # Always end with a 1 to make Perl happy 1; diff --git a/strings.txt b/strings.txt index 6e13ac8..b3ca9a7 100644 --- a/strings.txt +++ b/strings.txt @@ -49,11 +49,11 @@ PLUGIN_SQUEEZECLOUD_PLAYLIST_SEARCH PLUGIN_SQUEEZECLOUD_PLAYLIST_BROWSE EN Playlists -PLUGIN_SQUEEZECLOUD_APIKEY - EN Soundcloud API Key +PLUGIN_SQUEEZECLOUD_CODE + EN Soundcloud authorization code -PLUGIN_SQUEEZECLOUD_APIKEY_DESC - EN Paste your API key here, then save the settings +PLUGIN_SQUEEZECLOUD_CODE_DESC + EN Paste your autorization code after clicking the button above, then press Save PLUGIN_SQUEEZECLOUD_CONNECT_WITH_SOUNDCLOUD EN Login to SoundCloud @@ -88,8 +88,26 @@ PLUGIN_SQUEEZECLOUD_NO_INFO PLUGIN_SQUEEZECLOUD_ERROR EN SoundCloud error -PLUGIN_SQUEEZECLOUD_SET_API_KEY - EN Set your SoundCloud API key in Advanced settings +PLUGIN_SQUEEZECLOUD_LOGIN + EN Log in to SoundCloud in Advanced settings + +PLUGIN_SQUEEZECLOUD_LOGGING_IN + EN Logging you in... (should be good. Press Apply again, or refresh this page to see the result.) + +PLUGIN_SQUEEZECLOUD_LOGGED_IN_AS + EN Logged in as + +PLUGIN_SQUEEZECLOUD_LOG_OUT + EN Log out + +PLUGIN_SQUEEZECLOUD_LOGIN_ERROR + EN Something went wrong. Try to refresh the page. + +PLUGIN_SQUEEZECLOUD_USE_STREAM_METHOD + EN Always use stream method + +PLUGIN_SQUEEZECLOUD_USE_DOWNLOAD_METHOD + EN Use download method if available PLUGIN_SQUEEZECLOUD_FOLLOW EN Follow