Issues (3)

lib/BeatSaverAPI.php (3 issues)

1
<?php
2
3
namespace KriKrixs;
4
5
use KriKrixs\object\beatmap\BeatMap;
6
use KriKrixs\object\response\ResponseDownload;
7
use KriKrixs\object\response\ResponseMap;
8
use KriKrixs\object\response\ResponseMaps;
9
use KriKrixs\object\response\ResponseUser;
10
use KriKrixs\object\user\User;
11
12
class BeatSaverAPI
13
{
14
    const BEATSAVER_URL     = "https://api.beatsaver.com/";
15
    const BEATSAVER_CDN_URL = "https://cdn.beatsaver.com/";
16
    const MAX_HASHES_NUMBER = 50;
17
    const MAX_CALL_PER_SECS = 10;
18
19
    private string $userAgent;
20
21
    /**
22
     * BeatSaverAPI constructor
23
     * @param string $userAgent User Agent to provide to Beat Saver API
24
     * @param bool $needAutoloader If you don't use it with composer = true
25
     */
26
    public function __construct(string $userAgent, bool $needAutoloader = false)
27
    {
28
        if($needAutoloader)
29
            $this->autoload("./");
30
31
        $this->userAgent = $userAgent;
32
    }
33
34
    private function autoload($directory) {
35
        if(is_dir($directory)) {
36
            $scan = scandir($directory);
37
            unset($scan[0], $scan[1]); //unset . and ..
38
            foreach($scan as $file) {
39
                if(is_dir($directory."/".$file)) {
40
                    $this->autoload($directory."/".$file);
41
                } else {
42
                    if(strpos($file, '.php') !== false) {
43
                        include_once($directory."/".$file);
44
                    }
45
                }
46
            }
47
        }
48
    }
49
50
    /**
51
     * Private calling API function
52
     * @param string $endpoint
53
     * @return string|null
54
     */
55
    private function callAPI(string $endpoint): ?string
56
    {
57
        $curl = curl_init();
58
59
        curl_setopt_array($curl, [
60
            CURLOPT_URL => self::BEATSAVER_URL . $endpoint,
61
            CURLOPT_USERAGENT => $this->userAgent,
62
            CURLOPT_RETURNTRANSFER => true
63
        ]);
64
65
        $result = curl_exec($curl);
66
67
        curl_close($curl);
68
69
        return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result could return the type true which is incompatible with the type-hinted return null|string. Consider adding an additional type-check to rule them out.
Loading history...
70
    }
71
72
    ////////////
73
    /// CALL ///
74
    ///////////
75
76
    /**
77
     * Private building response functions
78
     * @param string $endpoint
79
     * @return ResponseMap
80
     */
81
    private function getMap(string $endpoint): ResponseMap
82
    {
83
        $response = new ResponseMap();
84
85
        $apiResult = $this->callAPI($endpoint);
86
87
        if($apiResult === false) {
0 ignored issues
show
The condition $apiResult === false is always false.
Loading history...
88
            $response->setErrorStatus(true)->setErrorMessage("[getMap] Something went wrong with the API call (" . $endpoint . ")");
89
            return $response;
90
        } elseif((json_decode($apiResult))->error == "Not Found") {
91
            $response->setErrorStatus(true)->setErrorMessage("[getMap] Map not found (" . $endpoint . ")");
92
            return $response;
93
        }
94
95
        $response->setBeatMap(new BeatMap(json_decode($apiResult)));
96
97
        return $response;
98
    }
99
100
    ///////////////
101
    /// Get Map ///
102
    ///////////////
103
104
    /**
105
     * Get map by ID (Same as BSR Key)
106
     * @param string $id Map ID
107
     * @return ResponseMap
108
     */
109
    public function getMapByID(string $id): ResponseMap
110
    {
111
        return $this->getMap("/maps/id/" . $id);
112
    }
113
114
    /**
115
     * Get map by BSR Key (Same as ID)
116
     * @param string $bsrKey Map BSR key
117
     * @return ResponseMap
118
     */
119
    public function getMapByKey(string $bsrKey): ResponseMap
120
    {
121
        return $this->getMap("/maps/id/" . $bsrKey);
122
    }
123
124
    /**
125
     * Get map by Hash
126
     * @param string $hash Hash of the map
127
     * @return ResponseMap
128
     */
129
    public function getMapByHash(string $hash): ResponseMap
130
    {
131
        return $this->getMap("/maps/hash/" . $hash);
132
    }
133
134
    ////////////////
135
    /// Get Maps ///
136
    ////////////////
137
138
    /**
139
     * Private building response functions
140
     * @param string $endpoint
141
     * @return ResponseMaps
142
     */
143
    private function getMaps(string $endpoint): ResponseMaps
144
    {
145
        $response = new ResponseMaps();
146
147
        $apiResult = $this->callAPI($endpoint);
148
149
        if($apiResult === false || $apiResult == "Not Found") {
150
            $response->setErrorStatus(true)->setErrorMessage("[getMap] Something went wrong with the API call.");
151
            return $response;
152
        }
153
154
        $response->setRawBeatMaps(json_decode($apiResult));
155
156
        return $response;
157
    }
158
159
    /**
160
     * Get maps by IDs (Same as BSR keys)
161
     * @param array $ids Array of maps ID (Same as BSR keys)
162
     * @return array Array of BeatMap object
163
     * @deprecated This function may end up with a 429 - Too many request. Use getMapsByHashes instead to avoid it.
164
     */
165
//    public function getMapsByIds(array $ids): array
166
//    {
167
//        return $this->multiQuery->DoMultiQuery($ids, false);
168
//    }
169
170
    /**
171
     * Get maps by BSR Keys (Same as IDs)
172
     * @param array $keys Array of maps BSR key (Same as IDs)
173
     * @return array Array of BeatMap object
174
     * @deprecated This function may end up with a 429 - Too many request. Use getMapsByHashes instead to avoid it.
175
     */
176
//    public function getMapsByKeys(array $keys): array
177
//    {
178
//        return $this->multiQuery->DoMultiQuery($keys, false);
179
//    }
180
181
    /**
182
     * Get maps by hashes
183
     * @param array $hashes Array of maps hash (minimum 2 hash)
184
     * @return ResponseMaps Array of BeatMap object
185
     */
186
    public function getMapsByHashes(array $hashes): ResponseMaps
187
    {
188
        $endpoint = "/maps/hash/";
189
        $hashesString = $endpoint;
190
        $mapsArray = [];
191
        $i = 0;
192
        $callNumber = 0;
193
        $result = new ResponseMaps();
194
195
        if(count($hashes) < 2) {
196
            return $result->setErrorStatus(true)->setErrorMessage("This functions require a minimum of 2 hashes in the array");
197
        }
198
199
        foreach($hashes as $hash) {
200
            $hashesString .= $hash;
201
202
            if($i !== 0 && $i % self::MAX_HASHES_NUMBER === 0) {
203
                if($callNumber === self::MAX_CALL_PER_SECS) {
204
                    sleep(1);
205
                    $callNumber = 0;
206
                }
207
208
                $maps = $this->getMaps($hashesString);
209
                $callNumber++;
210
211
                $mapsArray = array_merge($mapsArray, $maps->getBeatMaps());
212
213
                if(!isset($mapsArray["errorStatus"]) || !$mapsArray["errorStatus"]) {
214
                    $mapsArray["errorStatus"] = $maps->getErrorStatus();
215
                    $mapsArray["errorMessage"] = $maps->getErrorMessage();
216
                }
217
218
                $hashesString = $endpoint;
219
            } else {
220
                $hashesString .= ",";
221
            }
222
223
            $i++;
224
        }
225
226
227
        if($i !== 0) {
228
            $maps = $this->getMaps(substr($hashesString, 0, -1));
229
            $mapsArray = array_merge($mapsArray, $maps->getBeatMaps());
230
231
            if(!isset($mapsArray["errorStatus"]) || !$mapsArray["errorStatus"]) {
232
                $mapsArray["errorStatus"] = $maps->getErrorStatus();
233
                $mapsArray["errorMessage"] = $maps->getErrorMessage();
234
            }
235
        }
236
237
        if(isset($mapsArray["errorStatus"]) && $mapsArray["errorStatus"])
238
            $result->setErrorStatus( $mapsArray["errorStatus"])->setErrorMessage( $mapsArray["errorMessage"]);
239
240
        unset($mapsArray["errorStatus"]);
241
        unset($mapsArray["errorMessage"]);
242
243
        return $result->setBeatMaps($mapsArray);
244
    }
245
246
    /**
247
     * Private building response functions
248
     * @param string $endpoint
249
     * @param int $numberOfPage
250
     * @param int $startPage
251
     * @return ResponseMaps
252
     */
253
    private function getMapsByEndpoint(string $endpoint, int $numberOfPage = 0, int $startPage = 0): ResponseMaps
254
    {
255
        $response = new ResponseMaps();
256
        $maps = [];
257
        $callNumber = 0;
258
259
        // Latest
260
        if($numberOfPage === 0 && $startPage === 0){
261
            $apiResult = json_decode($this->callAPI(str_ireplace("page", 0, $endpoint)));
0 ignored issues
show
It seems like str_ireplace('page', 0, $endpoint) can also be of type array; however, parameter $endpoint of KriKrixs\BeatSaverAPI::callAPI() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

261
            $apiResult = json_decode($this->callAPI(/** @scrutinizer ignore-type */ str_ireplace("page", 0, $endpoint)));
Loading history...
262
263
            if($apiResult === false || $apiResult == "Not Found") {
264
                $response->setErrorStatus(true)->setErrorMessage("[getMaps] Something went wrong with the API call while calling the first page.");
265
                return $response;
266
            } else{
267
                foreach ($apiResult->docs as $beatmap) {
268
                    $maps[] = new BeatMap($beatmap);
269
                }
270
            }
271
        } else {
272
            for($i = $startPage; $i < ($startPage + $numberOfPage); $i++){
273
                if($callNumber === self::MAX_CALL_PER_SECS) {
274
                    sleep(1);
275
                    $callNumber = 0;
276
                }
277
278
                $apiResult = json_decode($this->callAPI(str_ireplace("page", $i, $endpoint)));
279
                $callNumber++;
280
281
                if($apiResult === false || $apiResult == "Not Found") {
282
                    $response->setErrorStatus(true)->setErrorMessage("[getMaps] Something went wrong with the API call while calling page number " . $i . ".");
283
284
                    if($apiResult == "Not Found")
285
                        return $response;
286
                }
287
288
                foreach ($apiResult->docs as $beatmap) {
289
                    $maps[] = new BeatMap($beatmap);
290
                }
291
            }
292
        }
293
294
        $response->setBeatMaps($maps);
295
296
        return $response;
297
    }
298
299
    /**
300
     * Get maps by Uploader ID! Not the uploader name!
301
     * @param int $uploaderID Uploader ID on BeatSaver
302
     * @param int $numberOfPage The number of page you want to be returned
303
     * @param int $startPage The starting page
304
     * @return ResponseMaps
305
     */
306
    public function getMapsByUploaderID(int $uploaderID, int $numberOfPage = 0, int $startPage = 0): ResponseMaps
307
    {
308
        return $this->getMapsByEndpoint("/maps/uploader/" . $uploaderID . "/page", $numberOfPage, $startPage);
309
    }
310
311
    /**
312
     * Get 20 latest maps
313
     * @param bool $autoMapper Do you want automapper or not ?
314
     * @return ResponseMaps
315
     */
316
    public function getMapsSortedByLatest(bool $autoMapper): ResponseMaps
317
    {
318
        return $this->getMapsByEndpoint("/maps/latest?automapper=" . var_export($autoMapper, true));
319
    }
320
321
    /**
322
     * Get maps sorted by plays numbers
323
     * @param int $numberOfPage The number of page you want to be returned
324
     * @param int $startPage The starting page
325
     * @return ResponseMaps
326
     */
327
    public function getMapsSortedByPlays(int $numberOfPage = 0, int $startPage = 0): ResponseMaps
328
    {
329
        return $this->getMapsByEndpoint("/maps/plays/page", $numberOfPage, $startPage);
330
    }
331
332
    /**
333
     * Search a map (Set null to a parameter to not use it)
334
     * @param int $startPage Start page number
335
     * @param int $numberOfPage Number of page wanted
336
     * @param int $sortOrder (Default 1) 1 = Latest | 2 = Relevance | 3 = Rating
337
     * @param string|null $mapName (Optional) Map name
338
     * @param \DateTime|null $startDate (Optional) Map made from this date
339
     * @param \DateTime|null $endDate (Optional) Map made to this date
340
     * @param bool $ranked (Optional) Want ranked or not ?
341
     * @param bool $automapper (Optional) Want automapper or not ?
342
     * @param bool $chroma (Optional) Want chroma or not ?
343
     * @param bool $noodle (Optional) Want noodle or not ?
344
     * @param bool $cinema (Optional) Want cinema or not ?
345
     * @param bool $fullSpread (Optional) Want fullSpread or not ?
346
     * @param float|null $minBpm (Optional) Minimum BPM
347
     * @param float|null $maxBpm (Optional) Maximum BPM
348
     * @param float|null $minNps (Optional) Minimum NPS
349
     * @param float|null $maxNps (Optional) Maximum NPS
350
     * @param float|null $minRating (Optional) Minimum Rating
351
     * @param float|null $maxRating (Optional) Maximum Rating
352
     * @param int|null $minDuration (Optional) Minimum Duration
353
     * @param int|null $maxDuration (Optional) Maximum Duration
354
     * @return ResponseMaps
355
     */
356
    public function searchMap(int $startPage = 0, int $numberOfPage = 1, int $sortOrder = 1, string $mapName = null, \DateTime $startDate = null, \DateTime $endDate = null, bool $ranked = false, bool $automapper = false, bool $chroma = false, bool $noodle = false, bool $cinema = false, bool $fullSpread = false, float $minBpm = null, float $maxBpm = null, float $minNps = null, float $maxNps = null, float $minRating = null, float $maxRating = null, int $minDuration = null, int $maxDuration = null): ResponseMaps
357
    {
358
        $sort = [
359
            1 => "Latest",
360
            2 => "Relevance",
361
            3 => "Rating"
362
        ];
363
364
        $endpoint = "/search/text/page?sortOrder=" . $sort[$sortOrder];
365
366
        if($mapName)                $endpoint .= "&q=" . urlencode($mapName);
367
        if($startDate)              $endpoint .= "&from=" . $startDate->format("Y-m-d");
368
        if($endDate)                $endpoint .= "&to=" . $endDate->format("Y-m-d");
369
        if($ranked)                 $endpoint .= "&ranked=" . /** @scrutinizer ignore-type */ var_export($ranked, true);
370
        if($automapper)             $endpoint .= "&automapper=" . /** @scrutinizer ignore-type */ var_export($automapper, true);
371
        if($chroma)                 $endpoint .= "&chroma=" . /** @scrutinizer ignore-type */ var_export($chroma, true);
372
        if($noodle)                 $endpoint .= "&noodle=" . /** @scrutinizer ignore-type */ var_export($noodle, true);
373
        if($cinema)                 $endpoint .= "&cinema=" . /** @scrutinizer ignore-type */ var_export($cinema, true);
374
        if($fullSpread)             $endpoint .= "&fullSpread=" . /** @scrutinizer ignore-type */ var_export($fullSpread, true);
375
        if($minBpm)                 $endpoint .= "&minBpm=" . /** @scrutinizer ignore-type */ $minBpm;
376
        if($maxBpm)                 $endpoint .= "&maxBpm=" . /** @scrutinizer ignore-type */ $maxBpm;
377
        if($minNps)                 $endpoint .= "&minNps=" . /** @scrutinizer ignore-type */ $minNps;
378
        if($maxNps)                 $endpoint .= "&maxNps=" . /** @scrutinizer ignore-type */ $maxNps;
379
        if($minRating)              $endpoint .= "&minRating=" . /** @scrutinizer ignore-type */ $minRating;
380
        if($maxRating)              $endpoint .= "&maxRating=" . /** @scrutinizer ignore-type */ $maxRating;
381
        if($minDuration !== null)   $endpoint .= "&minDuration=" . /** @scrutinizer ignore-type */ $minDuration;
382
        if($maxDuration !== null)   $endpoint .= "&maxDuration=" . /** @scrutinizer ignore-type */ $maxDuration;
383
384
        return $this->getMapsByEndpoint($endpoint, $numberOfPage, $startPage);
385
    }
386
387
    ////////////////
388
    /// Get User ///
389
    ////////////////
390
391
    /**
392
     * Private building response functions
393
     * @param string $endpoint
394
     * @return ResponseUser
395
     */
396
    private function getUser(string $endpoint): ResponseUser
397
    {
398
        $response = new ResponseUser();
399
400
        $apiResult = $this->callAPI($endpoint);
401
402
        if($apiResult === false || $apiResult == "Not Found") {
403
            $response->setErrorStatus(true)->setErrorMessage("[getMap] Something went wrong with the API call.");
404
            return $response;
405
        }
406
407
        $response->setUser(new User(json_decode($apiResult)));
408
409
        return $response;
410
    }
411
412
    /**
413
     * Get user's infos by UserID
414
     * @param int $id User ID
415
     * @return ResponseUser
416
     */
417
    public function getUserByID(int $id): ResponseUser
418
    {
419
        return $this->getUser("/users/id/" . $id);
420
    }
421
422
    ////////////////////
423
    /// Download map ///
424
    ////////////////////
425
426
    private function downloadMapZipAndCover(array $hashes, string $targetDir): ResponseDownload
427
    {
428
        $response = new ResponseDownload();
429
430
        if (!file_exists($targetDir)) {
431
            mkdir($targetDir, 0777, true);
432
        }
433
434
        if(substr($targetDir, -1) !== "/")
435
            $targetDir .= "/";
436
437
        foreach ($hashes as $hash) {
438
            //The path & filename to save to.
439
            $saveTo = $targetDir . $hash;
440
441
            echo $hash . ": ";
442
443
            $error = false;
444
445
            echo "map" . ": ";
446
447
            $ch = curl_init(self::BEATSAVER_CDN_URL . $hash . ".zip");
448
            curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
449
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
450
            curl_setopt($ch, CURLOPT_ENCODING, "");
451
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
452
453
            $result = curl_exec($ch);
454
455
            if(curl_errno($ch) === 0) {
456
                $response->pushDownloadMapHash($hash);
457
                echo "Ok ";
458
            } else {
459
                $response->pushFailMapHash($hash)->setErrorStatus(true)->setErrorMessage("Something went wrong with some maps");
460
                $error = true;
461
                echo "Error ";
462
            }
463
464
            file_put_contents($saveTo . ".zip", $result);
465
466
            curl_close($ch);
467
468
            echo "cover" . ": ";
469
470
            $ch = curl_init(self::BEATSAVER_CDN_URL . $hash . ".jpg");
471
            curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
472
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
473
            curl_setopt($ch, CURLOPT_ENCODING, "");
474
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
475
476
            $result = curl_exec($ch);
477
478
            if(curl_errno($ch) === 0) {
479
                $response->pushDownloadMapHash($hash);
480
                echo "Ok ";
481
            } else {
482
                $response->pushFailMapHash($hash)->setErrorStatus(true)->setErrorMessage("Something went wrong with some maps");
483
                $error = true;
484
                echo "Error ";
485
            }
486
487
            file_put_contents($saveTo . ".jpg", $result);
488
489
            curl_close($ch);
490
491
            echo "save: " . ($error ? "No" : "Yes") . "\n";
492
        }
493
494
        return $response;
495
    }
496
497
    /**
498
     * Download maps using id (Same as BSR Key)
499
     * @param array $ids Array of maps IDs (Same as BSR Key)
500
     * @param string $targetDir Path to download dir
501
     * @deprecated Will not work, use downloadMapByHashes instead
502
     * @return ResponseDownload
503
     */
504
    public function downloadMapByIds(array $ids, string $targetDir): ResponseDownload
505
    {
506
        return $this->downloadMapZipAndCover($ids, $targetDir);
507
    }
508
509
    /**
510
     * Download maps using bsr key (Same as ID)
511
     * @param array $keys Array of maps keys (Same as ID)
512
     * @param string $targetDir Path to download dir
513
     * @deprecated Will not work, use downloadMapByHashes instead
514
     * @return ResponseDownload
515
     */
516
    public function downloadMapByKeys(array $keys, string $targetDir): ResponseDownload
517
    {
518
        return $this->downloadMapZipAndCover($keys, $targetDir);
519
    }
520
521
    /**
522
     * Download maps using hashes
523
     * @param array $hashes Array of maps hashes
524
     * @param string $targetDir Path to download dir
525
     * @return ResponseDownload
526
     */
527
    public function downloadMapByHashes(array $hashes, string $targetDir): ResponseDownload
528
    {
529
        return $this->downloadMapZipAndCover($hashes, $targetDir);
530
    }
531
}
532