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}