friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class LastFM 4 * 5 * @created 10.04.2018 6 * @author Smiley <smiley@chillerlan.net> 7 * @copyright 2018 Smiley 8 * @license MIT 9 * 10 * @noinspection PhpUnused 11 */ 12declare(strict_types=1); 13 14namespace chillerlan\OAuth\Providers; 15 16use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil}; 17use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, OAuthProvider, UserInfo}; 18use chillerlan\Settings\SettingsContainerAbstract; 19use Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface, UriInterface}; 20use DateTimeInterface, InvalidArgumentException, Throwable; 21use function array_chunk, array_filter, array_merge, in_array, is_array, ksort, md5, sprintf, strtoupper, trim; 22 23/** 24 * Last.fm 25 * 26 * @link https://www.last.fm/api/authentication 27 */ 28class LastFM extends OAuthProvider implements UserInfo{ 29 30 public const IDENTIFIER = 'LASTFM'; 31 32 public const PERIOD_OVERALL = 'overall'; 33 public const PERIOD_7DAY = '7day'; 34 public const PERIOD_1MONTH = '1month'; 35 public const PERIOD_3MONTH = '3month'; 36 public const PERIOD_6MONTH = '6month'; 37 public const PERIOD_12MONTH = '12month'; 38 39 public const PERIODS = [ 40 self::PERIOD_OVERALL, 41 self::PERIOD_7DAY, 42 self::PERIOD_1MONTH, 43 self::PERIOD_3MONTH, 44 self::PERIOD_6MONTH, 45 self::PERIOD_12MONTH, 46 ]; 47 48 protected string $authorizationURL = 'https://www.last.fm/api/auth'; 49 protected string $accessTokenURL = 'https://ws.audioscrobbler.com/2.0'; 50 protected string $apiURL = 'https://ws.audioscrobbler.com/2.0'; 51 protected string|null $userRevokeURL = 'https://www.last.fm/settings/applications'; 52 protected string|null $apiDocs = 'https://www.last.fm/api/'; 53 protected string|null $applicationURL = 'https://www.last.fm/api/account/create'; 54 55 /** @var array<int, array<string, scalar>> */ 56 protected array $scrobbles = []; 57 58 public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 59 60 $params = array_merge(($params ?? []), [ 61 'api_key' => $this->options->key, 62 ]); 63 64 return $this->uriFactory->createUri(QueryUtil::merge($this->authorizationURL, $params)); 65 } 66 67 /** 68 * Obtains an authentication token 69 */ 70 public function getAccessToken(string $session_token):AccessToken{ 71 $params = $this->getAccessTokenRequestBodyParams($session_token); 72 $response = $this->sendAccessTokenRequest($this->accessTokenURL, $params); 73 $token = $this->parseTokenResponse($response); 74 75 $this->storage->storeAccessToken($token, $this->name); 76 77 return $token; 78 } 79 80 /** 81 * prepares the request body parameters for the access token request 82 * 83 * @return array<string, scalar|bool|null> 84 */ 85 protected function getAccessTokenRequestBodyParams(string $session_token):array{ 86 87 $params = [ 88 'method' => 'auth.getSession', 89 'format' => 'json', 90 'api_key' => $this->options->key, 91 'token' => $session_token, 92 ]; 93 94 return $this->addSignature($params); 95 } 96 97 /** 98 * sends a request to the access token endpoint $url with the given $params as URL query 99 * 100 * @param array<string, scalar|bool|null> $params 101 */ 102 protected function sendAccessTokenRequest(string $url, array $params):ResponseInterface{ 103 104 $request = $this->requestFactory 105 ->createRequest('GET', QueryUtil::merge($url, $params)) 106 ->withHeader('Accept', 'application/json') 107 ->withHeader('Accept-Encoding', 'identity') 108 ->withHeader('Content-Length', '0') 109 ; 110 111 return $this->http->sendRequest($request); 112 } 113 114 /** 115 * @throws \chillerlan\OAuth\Providers\ProviderException 116 */ 117 protected function parseTokenResponse(ResponseInterface $response):AccessToken{ 118 119 try{ 120 $data = MessageUtil::decodeJSON($response, true); 121 122 if(!is_array($data)){ 123 throw new ProviderException; 124 } 125 } 126 catch(Throwable){ 127 throw new ProviderException('unable to parse token response'); 128 } 129 130 if(isset($data['error'])){ 131 throw new ProviderException(sprintf('error retrieving access token: "%s"', $data['message'])); 132 } 133 134 if(!isset($data['session']['key'])){ 135 throw new ProviderException('token missing'); 136 } 137 138 $token = $this->createAccessToken(); 139 140 $token->accessToken = $data['session']['key']; 141 $token->expires = AccessToken::NEVER_EXPIRES; 142 143 unset($data['session']['key']); 144 145 $token->extraParams = $data; 146 147 return $token; 148 } 149 150 public function request( 151 string $path, 152 array|null $params = null, 153 string|null $method = null, 154 StreamInterface|array|string|null $body = null, 155 array|null $headers = null, 156 string|null $protocolVersion = null, 157 ):ResponseInterface{ 158 $method = strtoupper(($method ?? 'GET')); 159 $headers ??= []; 160 161 if($body !== null && !is_array($body)){ 162 throw new InvalidArgumentException('$body must be an array'); 163 } 164 165 // all parameters go either in the query or in the body - there is no in-between 166 $params = array_merge(($params ?? []), ($body ?? []), ['method' => $path]); 167 168 if(!isset($params['format'])){ 169 $params['format'] = 'json'; 170 $headers['Accept'] = 'application/json'; 171 } 172 173 // request authorization is always part of the parameter array 174 $params = $this->getAuthorization($params); 175 176 if($method === 'POST'){ 177 $body = $params; 178 $params = []; 179 180 $headers['Content-Type'] = 'application/x-www-form-urlencoded'; 181 } 182 183 return parent::request('', $params, $method, $body, $headers, $protocolVersion); 184 } 185 186 /** 187 * adds the authorization parameters to the request parameters 188 * 189 * @param array<string, scalar|bool|null> $params 190 * @return array<string, scalar|bool|null> 191 */ 192 protected function getAuthorization(array $params, AccessToken|null $token = null):array{ 193 $token ??= $this->storage->getAccessToken($this->name); 194 195 $params = array_merge($params, [ 196 'api_key' => $this->options->key, 197 'sk' => $token->accessToken, 198 ]); 199 200 return $this->addSignature($params); 201 } 202 203 public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{ 204 // noop - just return the request 205 return $request; 206 } 207 208 /** 209 * returns the signature for the set of parameters 210 * 211 * @param array<string, string> $params 212 * @return array<string, string> 213 */ 214 protected function addSignature(array $params):array{ 215 216 if(!isset($params['api_key'])){ 217 throw new ProviderException('"api_key" missing'); // @codeCoverageIgnore 218 } 219 220 ksort($params); 221 222 $signature = ''; 223 224 foreach($params as $k => $v){ 225 226 if(in_array($k, ['format', 'callback'], true)){ 227 continue; 228 } 229 230 $signature .= $k.$v; 231 } 232 233 $params['api_sig'] = md5($signature.$this->options->secret); 234 235 return $params; 236 } 237 238 protected function sendMeRequest(string $endpoint, array|null $params = null):ResponseInterface{ 239 return $this->request($endpoint, $params); 240 } 241 242 /** @codeCoverageIgnore */ 243 public function me():AuthenticatedUser{ 244 $json = $this->getMeResponseData('user.getInfo'); 245 246 $userdata = [ 247 'data' => $json, 248 'avatar' => $json['user']['image'][3]['#text'], 249 'handle' => $json['user']['name'], 250 'displayName' => $json['user']['realname'], 251 'url' => $json['user']['url'], 252 ]; 253 254 return new AuthenticatedUser($userdata); 255 } 256 257 /** 258 * Scrobbles an array of one or more tracks 259 * 260 * There is no limit for adding tracks, they will be sent to the API in chunks of 50 automatically. 261 * The return value of this method is an array that contains a response array for each 50 tracks sent, 262 * if an error happened, the element will be null. 263 * 264 * Each track array may consist of the following values 265 * 266 * - artist : [required] The artist name. 267 * - track : [required] The track name. 268 * - timestamp : [required] The time the track started playing, in UNIX timestamp format (UTC time zone). 269 * - album : [optional] The album name. 270 * - context : [optional] Sub-client version (not public, only enabled for certain API keys) 271 * - streamId : [optional] The stream id for this track received from the radio.getPlaylist service, 272 * if scrobbling Last.fm radio (unavailable) 273 * - chosenByUser: [optional] Set to 1 if the user chose this song, or 0 if the song was chosen by someone else 274 * (such as a radio station or recommendation service). Assumes 1 if not specified 275 * - trackNumber : [optional] The track number of the track on the album. 276 * - mbid : [optional] The MusicBrainz Track ID. 277 * - albumArtist : [optional] The album artist - if this differs from the track artist. 278 * - duration : [optional] The length of the track in seconds. 279 * 280 * @link https://www.last.fm/api/show/track.scrobble 281 */ 282 public function scrobble(array $tracks):array{ // phpcs:ignore 283 284 // a single track was given 285 if(isset($tracks['artist'], $tracks['track'], $tracks['timestamp'])){ 286 $tracks = [$tracks]; 287 } 288 289 foreach($tracks as $track){ 290 $this->addScrobble($track); 291 } 292 293 if($this->scrobbles === []){ 294 throw new InvalidArgumentException('no tracks to scrobble'); // @codeCoverageIgnore 295 } 296 297 // we're going to collect the responses in an array 298 $return = []; 299 300 // 50 tracks max per request 301 foreach(array_chunk($this->scrobbles, 50) as $chunk){ 302 $body = []; 303 304 foreach($chunk as $i => $track){ 305 foreach($track as $key => $value){ 306 $body[sprintf('%s[%s]', $key, $i)] = $value; 307 } 308 } 309 310 $return[] = $this->sendScrobbles($body); 311 } 312 313 return $return; 314 } 315 316 /** 317 * Adds a track to scrobble 318 * 319 * @param array<string, scalar> $track 320 */ 321 public function addScrobble(array $track):static{ 322 323 if(!isset($track['artist'], $track['track'], $track['timestamp'])){ 324 throw new InvalidArgumentException('"artist", "track" and "timestamp" are required'); // @codeCoverageIgnore 325 } 326 327 $this->scrobbles[] = $this->parseTrack($track); 328 329 return $this; 330 } 331 332 /** @codeCoverageIgnore */ 333 public function clearScrobbles():static{ 334 $this->scrobbles = []; 335 336 return $this; 337 } 338 339 /** 340 * @param array<string, scalar> $track 341 * @codeCoverageIgnore 342 */ 343 protected function parseTrack(array $track):array{ 344 // we're using the settings container and its setters to enforce variables and types etc. 345 $parser = new class ($track) extends SettingsContainerAbstract{ 346 347 protected string $artist; 348 protected string $track; 349 protected int $timestamp; 350 protected string|null $album = null; 351 protected string|null $context = null; 352 protected string|null $streamId = null; 353 protected int $chosenByUser = 1; 354 protected int|null $trackNumber = null; 355 protected string|null $mbid = null; 356 protected string|null $albumArtist = null; 357 protected int|null $duration = null; 358 359 protected function construct():void{ 360 foreach(['artist', 'track', 'album', 'context', 'streamId', 'mbid', 'albumArtist'] as $var){ 361 362 if($this->{$var} === null){ 363 continue; 364 } 365 366 $this->{$var} = trim($this->{$var}); 367 368 if($this->{$var} === ''){ 369 throw new InvalidArgumentException(sprintf('variable "%s" must not be empty', $var)); 370 } 371 } 372 } 373 374 public function toArray():array{ 375 // filter out the null values 376 return array_filter(parent::toArray(), fn(mixed $val):bool => $val !== null); 377 } 378 379 protected function set_timestamp(DateTimeInterface|int $timestamp):void{ 380 381 if($timestamp instanceof DateTimeInterface){ 382 $timestamp = $timestamp->getTimestamp(); 383 } 384 385 $this->timestamp = $timestamp; 386 } 387 388 protected function set_chosenByUser(bool $chosenByUser):void{ 389 $this->chosenByUser = (int)$chosenByUser; 390 } 391 392 protected function set_trackNumber(int $trackNumber):void{ 393 394 if($trackNumber < 1){ 395 throw new InvalidArgumentException('invalid track number'); 396 } 397 398 $this->trackNumber = $trackNumber; 399 } 400 401 protected function set_duration(int $duration):void{ 402 403 if($duration < 0){ 404 throw new InvalidArgumentException('invalid track duration'); 405 } 406 407 $this->duration = $duration; 408 } 409 410 }; 411 412 return $parser->toArray(); 413 } 414 415 /** 416 * @param array<string, string> $body 417 * @codeCoverageIgnore 418 */ 419 protected function sendScrobbles(array $body):array|null{ 420 421 $response = $this->request( 422 path : 'track.scrobble', 423 method: 'POST', 424 body : $body, 425 ); 426 427 if($response->getStatusCode() === 200){ 428 $json = MessageUtil::decodeJSON($response, true); 429 430 if(!isset($json['scrobbles'])){ 431 return null; 432 } 433 434 return $json['scrobbles']; 435 } 436 437 return null; 438 } 439 440}