Passed
Push — master ( 1664a5...9b82e4 )
by Darko
05:59
created

PopulateAniDB::processAPIResponseElement()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 13
rs 9.6111
c 0
b 0
f 0
cc 5
nc 6
nop 3
1
<?php
2
3
namespace Blacklight;
4
5
use App\Models\AnidbEpisode;
6
use App\Models\AnidbInfo;
7
use App\Models\AnidbTitle;
8
use App\Models\Settings;
9
use Illuminate\Support\Carbon;
10
11
class PopulateAniDB
12
{
13
    private const CLIENT_VERSION = 2;
14
15
    /**
16
     * Whether to echo message output.
17
     */
18
    public bool $echooutput;
19
20
    /**
21
     * The directory to store AniDB covers.
22
     */
23
    public string $imgSavePath;
24
25
    /**
26
     * The name of the nZEDb client for AniDB lookups.
27
     */
28
    private string $apiKey;
29
30
    /**
31
     * Whether AniDB thinks our client is banned.
32
     */
33
    private bool $banned;
34
35
    /**
36
     * The last unixtime a full AniDB update was run.
37
     */
38
    private string $lastUpdate;
39
40
    /**
41
     * The number of days between full AniDB updates.
42
     */
43
    private string $updateInterval;
44
45
    protected ColorCLI $colorCli;
46
47
    /**
48
     * @param  array  $options  Class instances / Echo to cli.
49
     *
50
     * @throws \Exception
51
     */
52
    public function __construct()
53
    {
54
        $this->echooutput = config('nntmux.echocli');
55
        $this->colorCli = new ColorCLI();
56
57
        $anidbupdint = Settings::settingValue('APIs.AniDB.max_update_frequency');
58
        $lastupdated = Settings::settingValue('APIs.AniDB.last_full_update');
59
60
        $this->imgSavePath = storage_path('covers/anime/');
61
        $this->apiKey = config('nntmux_api.anidb_api_key');
62
63
        $this->updateInterval = $anidbupdint ?? '7';
64
        $this->lastUpdate = $lastupdated ?? '0';
65
        $this->banned = false;
66
    }
67
68
    /**
69
     * Main switch that initiates AniDB table population.
70
     *
71
     *
72
     * @throws \Exception
73
     */
74
    public function populateTable(string $type = '', int|string $aniDbId = ''): void
75
    {
76
        switch ($type) {
77
            case 'full':
78
                $this->populateMainTable();
79
                break;
80
            case 'info':
81
                $this->populateInfoTable($aniDbId);
82
                break;
83
        }
84
    }
85
86
    /**
87
     * @return array|false
88
     *
89
     * @throws \Exception
90
     */
91
    private function getAniDbAPI($aniDbId)
92
    {
93
        $timestamp = Settings::settingValue('APIs.AniDB.banned') + 90000;
94
        if ($timestamp > time()) {
95
            echo 'Banned from AniDB lookups until '.date('Y-m-d H:i:s', $timestamp).PHP_EOL;
96
97
            return false;
98
        }
99
        $apiResponse = $this->getAniDbResponse($aniDbId);
100
101
        $AniDBAPIArray = [];
102
103
        if ($apiResponse === false) {
0 ignored issues
show
introduced by
The condition $apiResponse === false is always false.
Loading history...
104
            echo 'AniDB: Error getting response.'.PHP_EOL;
105
        } elseif (preg_match('/\<error\>Banned\<\/error\>/', $apiResponse)) {
106
            $this->banned = true;
107
            Settings::query()->where(['section' => 'APIs', 'subsection' => 'AniDB', 'name' => 'banned'])->update(['value' => time()]);
108
        } elseif (preg_match('/\<error\>Anime not found\<\/error\>/', $apiResponse)) {
109
            echo "AniDB   : Anime not yet on site. Remove until next update.\n";
110
        } elseif ($AniDBAPIXML = new \SimpleXMLElement($apiResponse)) {
111
            $AniDBAPIArray['similar'] = $this->processAPIResponseElement($AniDBAPIXML->similaranime, 'anime', false);
112
            $AniDBAPIArray['related'] = $this->processAPIResponseElement($AniDBAPIXML->relatedanime, 'anime', false);
113
            $AniDBAPIArray['creators'] = $this->processAPIResponseElement($AniDBAPIXML->creators, null, false);
114
            $AniDBAPIArray['characters'] = $this->processAPIResponseElement($AniDBAPIXML->characters, null, true);
115
            $AniDBAPIArray['categories'] = $this->processAPIResponseElement($AniDBAPIXML->categories, null, true);
116
117
            $episodeArray = [];
118
            if ($AniDBAPIXML->episodes && $AniDBAPIXML->episodes->episode[0]->attributes()) {
119
                $i = 1;
120
                foreach ($AniDBAPIXML->episodes->episode as $episode) {
121
                    $titleArray = [];
122
123
                    $episodeArray[$i]['episode_id'] = (int) $episode->attributes()->id;
0 ignored issues
show
Bug introduced by
The method attributes() does not exist on null. ( Ignorable by Annotation )

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

123
                    $episodeArray[$i]['episode_id'] = (int) $episode->/** @scrutinizer ignore-call */ attributes()->id;

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
124
                    $episodeArray[$i]['episode_no'] = (int) $episode->epno;
125
                    $episodeArray[$i]['airdate'] = (string) $episode->airdate;
126
127
                    if (! empty($episode->title)) {
128
                        foreach ($episode->title as $title) {
129
                            $xmlAttribs = $title->attributes('xml', true);
0 ignored issues
show
Bug introduced by
The method attributes() does not exist on null. ( Ignorable by Annotation )

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

129
                            /** @scrutinizer ignore-call */ 
130
                            $xmlAttribs = $title->attributes('xml', true);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
130
                            // only english, x-jat imploded episode titles for now
131
                            if (\in_array($xmlAttribs->lang, ['en', 'x-jat'], false)) {
132
                                $titleArray[] = $title[0];
133
                            }
134
                        }
135
                    }
136
137
                    $episodeArray[$i]['episode_title'] = empty($titleArray) ? '' : implode(', ', $titleArray);
138
                    $i++;
139
                }
140
            }
141
142
            //start and end date come from AniDB API as date strings -- no manipulation needed
143
            $AniDBAPIArray['startdate'] = $AniDBAPIXML->startdate ?? '0000-00-00';
144
            $AniDBAPIArray['enddate'] = $AniDBAPIXML->enddate ?? '0000-00-00';
145
146
            if (isset($AniDBAPIXML->ratings->permanent)) {
147
                $AniDBAPIArray['rating'] = $AniDBAPIXML->ratings->permanent;
148
            } else {
149
                $AniDBAPIArray['rating'] = $AniDBAPIXML->ratings->temporary ?? '';
150
            }
151
152
            $AniDBAPIArray += [
153
                'type' => isset($AniDBAPIXML->type[0]) ? (string) $AniDBAPIXML->type : '',
154
                'description' => isset($AniDBAPIXML->description) ? (string) $AniDBAPIXML->description : '',
155
                'picture' => isset($AniDBAPIXML->picture[0]) ? (string) $AniDBAPIXML->picture : '',
156
                'epsarr' => $episodeArray,
157
            ];
158
159
            return $AniDBAPIArray;
160
        }
161
162
        return false;
163
    }
164
165
    private function processAPIResponseElement(\SimpleXMLElement $element, ?string $property = null, bool $children = false): string
166
    {
167
        $property = $property ?? 'name';
168
        $temp = '';
169
170
        if (count($element) !== 0) {
171
            $result = $children === true ? $element->children() : $element;
172
            foreach ($result as $entry) {
173
                $temp .= $entry->$property.', ';
174
            }
175
        }
176
177
        return empty($temp) ? '' : substr($temp, 0, -2);
178
    }
179
180
    /**
181
     * Requests and returns the API data from AniDB.
182
     */
183
    private function getAniDbResponse($aniDbId): string
184
    {
185
        $curlString = sprintf(
186
            'http://api.anidb.net:9001/httpapi?request=anime&client=%s&clientver=%d&protover=1&aid=%d',
187
            $this->apiKey,
188
            self::CLIENT_VERSION,
189
            $aniDbId
190
        );
191
192
        $ch = curl_init($curlString);
193
194
        $curlOpts = [
195
            CURLOPT_RETURNTRANSFER => 1,
196
            CURLOPT_HEADER => 0,
197
            CURLOPT_FAILONERROR => 1,
198
            CURLOPT_ENCODING => 'gzip',
199
        ];
200
201
        curl_setopt_array($ch, $curlOpts);
202
        $apiResponse = curl_exec($ch);
203
        curl_close($ch);
204
205
        return $apiResponse;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $apiResponse could return the type true which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
206
    }
207
208
    /**
209
     * Inserts new anime info from AniDB to anidb table.
210
     *
211
     * @param  int  $id  The AniDB ID to be inserted
212
     * @param  string  $type  The title type
213
     * @param  string  $lang  The title language
214
     * @param  string  $title  The title of the Anime
215
     */
216
    private function insertAniDb(int $id, string $type, string $lang, string $title): void
217
    {
218
        $check = AnidbTitle::query()->where(['anidbid' => $id, 'type' => $type, 'lang' => $lang, 'title' => $title])->first();
219
220
        if ($check === null) {
221
            AnidbTitle::insertOrIgnore(['anidbid' => $id, 'type' => $type, 'lang' => $lang, 'title' => $title]);
222
        } else {
223
            $this->colorCli->warning("Duplicate: $id");
224
        }
225
    }
226
227
    private function insertAniDBInfoEps($aniDbId, array $AniDBInfoArray = []): string
228
    {
229
        AnidbInfo::query()
230
            ->insert(
231
                [
232
                    'anidbid' => $aniDbId,
233
                    'type' => $AniDBInfoArray['type'],
234
                    'startdate' => $AniDBInfoArray['startdate'],
235
                    'enddate' => $AniDBInfoArray['enddate'],
236
                    'related' => $AniDBInfoArray['enddate'],
237
                    'similar' => $AniDBInfoArray['similar'],
238
                    'creators' => $AniDBInfoArray['creators'],
239
                    'description' => $AniDBInfoArray['description'],
240
                    'rating' => $AniDBInfoArray['rating'],
241
                    'picture' => $AniDBInfoArray['picture'],
242
                    'categories' => $AniDBInfoArray['categories'],
243
                    'characters' => $AniDBInfoArray['characters'],
244
                    'updated' => now(),
245
                ]
246
            );
247
        if (! empty($AniDBInfoArray['epsarr'])) {
248
            $this->insertAniDBEpisodes($aniDbId, $AniDBInfoArray['epsarr']);
249
        }
250
251
        return $AniDBInfoArray['picture'];
252
    }
253
254
    private function insertAniDBEpisodes($aniDbId, array $episodeArr = []): void
255
    {
256
        if (! empty($episodeArr)) {
257
            foreach ($episodeArr as $episode) {
258
                AnidbEpisode::insertOrIgnore(
259
                    [
260
                        'anidbid' => $aniDbId,
261
                        'episodeid' => $episode['episode_id'],
262
                        'episode_no' => $episode['episode_no'],
263
                        'episode_title' => $episode['episode_title'],
264
                        'airdate' => $episode['airdate'],
265
                    ]
266
                );
267
            }
268
        }
269
    }
270
271
    /**
272
     *  Grabs AniDB Full Dump XML and inserts it into anidb table.
273
     */
274
    private function populateMainTable(): void
275
    {
276
        $lastUpdate = Carbon::createFromTimestamp($this->lastUpdate);
277
        $current = now();
278
279
        if ($current->diff($lastUpdate)->format('%d') > $this->updateInterval) {
280
            if ($this->echooutput) {
281
                $this->colorCli->header('Updating anime titles by grabbing full data AniDB dump.');
282
            }
283
284
            $animeTitles = new \SimpleXMLElement('compress.zlib://http://anidb.net/api/anime-titles.xml.gz', null, true);
285
286
            //Even if the update process fails,
287
            //we must mark the last update time or risk ban
288
            $this->setLastUpdated();
289
290
            if ($animeTitles) {
0 ignored issues
show
introduced by
$animeTitles is of type SimpleXMLElement, thus it always evaluated to true.
Loading history...
291
                $count = $animeTitles->count();
292
                if ($this->echooutput) {
293
                    $this->colorCli->header(
294
                        'Total of '.number_format($count).' titles to add.'.PHP_EOL
295
                    );
296
                }
297
298
                foreach ($animeTitles as $anime) {
299
                    echo "Remaining: $count  \r";
300
                    foreach ($anime->title as $title) {
301
                        $xmlAttribs = $title->attributes('xml', true);
302
                        $this->insertAniDb(
303
                            (string) $anime['aid'],
0 ignored issues
show
Bug introduced by
(string)$anime['aid'] of type string is incompatible with the type integer expected by parameter $id of Blacklight\PopulateAniDB::insertAniDb(). ( Ignorable by Annotation )

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

303
                            /** @scrutinizer ignore-type */ (string) $anime['aid'],
Loading history...
304
                            (string) $title['type'],
305
                            (string) $xmlAttribs->lang,
306
                            (string) $title[0]
307
                        );
308
                        $this->colorCli->primary(
309
                            sprintf(
310
                                'Inserting: %d, %s, %s, %s',
311
                                $anime['aid'],
312
                                $title['type'],
313
                                $xmlAttribs->lang,
314
                                $title[0]
315
                            )
316
                        );
317
                    }
318
                    $count--;
319
                }
320
            } else {
321
                echo PHP_EOL.
322
                    $this->colorCli->error('Error retrieving XML data from AniDB. Please try again later.').
323
                    PHP_EOL;
324
            }
325
        } else {
326
            $this->colorCli->info(
327
                'AniDB has been updated within the past '.$this->updateInterval.' days. '.
328
                'Either set this value lower in Site Edit (at your own risk of being banned) or try again later.'
329
            );
330
        }
331
    }
332
333
    /**
334
     * Directs flow for populating the AniDB Info/Episodes table.
335
     *
336
     *
337
     * @throws \Exception
338
     */
339
    private function populateInfoTable(string $aniDbId = ''): void
340
    {
341
        if (empty($aniDbId)) {
342
            $anidbIds = AnidbTitle::query()
343
                ->selectRaw('DISTINCT anidb_titles.anidbid')
344
                ->leftJoin('anidb_info as ai', 'ai.anidbid', '=', 'anidb_titles.anidbid')
345
                ->whereNull('ai.updated')
346
                ->get();
347
348
            foreach ($anidbIds as $anidb) {
349
                $AniDBAPIArray = $this->getAniDbAPI($anidb['anidbid']);
350
351
                if ($this->banned) {
352
                    $this->colorCli->error(
353
                        'AniDB Banned, import will fail, please wait 24 hours before retrying.'
354
                    );
355
                    exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
356
                }
357
358
                if ($AniDBAPIArray === false && $this->echooutput) {
359
                    $this->colorCli->info(
360
                        'Anime ID: '.$anidb['anidbid'].' not available for update yet.'
361
                    );
362
                } else {
363
                    $this->updateAniChildTables($anidb['anidbid'], $AniDBAPIArray);
0 ignored issues
show
Bug introduced by
It seems like $AniDBAPIArray can also be of type false; however, parameter $AniDBInfoArray of Blacklight\PopulateAniDB::updateAniChildTables() does only seem to accept array, 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

363
                    $this->updateAniChildTables($anidb['anidbid'], /** @scrutinizer ignore-type */ $AniDBAPIArray);
Loading history...
364
                }
365
                sleep(random_int(120, 240));
366
            }
367
        } else {
368
            $AniDBAPIArray = $this->getAniDbAPI($aniDbId);
369
370
            if ($this->banned) {
371
                $this->colorCli->error(
372
                    'AniDB Banned, import will fail, please wait 24 hours before retrying.'
373
                );
374
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
375
            }
376
377
            if ($AniDBAPIArray === false && $this->echooutput) {
378
                $this->colorCli->info(
379
                    'Anime ID: '.$aniDbId.' not available for update yet.'
380
                );
381
            } else {
382
                $this->updateAniChildTables($aniDbId, $AniDBAPIArray);
383
            }
384
        }
385
    }
386
387
    /**
388
     * Sets the database time for last full AniDB update.
389
     */
390
    private function setLastUpdated(): void
391
    {
392
        Settings::query()->where(['section' => 'APIs', 'subsection' => 'AniDB', 'name' => 'last_full_update'])->update(['value' => time()]);
393
    }
394
395
    private function updateAniDBInfoEps($aniDbId, array $AniDBInfoArray = []): string
396
    {
397
        AnidbInfo::query()
398
            ->where('anidbid', $aniDbId)
399
            ->update(
400
                [
401
                    'type' => $AniDBInfoArray['type'],
402
                    'startdate' => $AniDBInfoArray['startdate'],
403
                    'enddate' => $AniDBInfoArray['enddate'],
404
                    'related' => $AniDBInfoArray['enddate'],
405
                    'similar' => $AniDBInfoArray['similar'],
406
                    'creators' => $AniDBInfoArray['creators'],
407
                    'description' => $AniDBInfoArray['description'],
408
                    'rating' => $AniDBInfoArray['rating'],
409
                    'picture' => $AniDBInfoArray['picture'],
410
                    'categories' => $AniDBInfoArray['categories'],
411
                    'characters' => $AniDBInfoArray['characters'],
412
                    'updated' => now(),
413
                ]
414
            );
415
        if (! empty($AniDBInfoArray['epsarr'])) {
416
            $this->insertAniDBEpisodes($aniDbId, $AniDBInfoArray['epsarr']);
417
        }
418
419
        return $AniDBInfoArray['picture'];
420
    }
421
422
    private function updateAniChildTables($aniDbId, array $AniDBInfoArray = []): void
423
    {
424
        $check = AnidbInfo::query()->where('anidbid', $aniDbId)->first(['anidbid']);
425
426
        if ($check === null) {
427
            $picture = $this->insertAniDBInfoEps($aniDbId, $AniDBInfoArray);
428
        } else {
429
            $picture = $this->updateAniDBInfoEps($aniDbId, $AniDBInfoArray);
430
        }
431
432
        if (! empty($picture) && ! file_exists($this->imgSavePath.$aniDbId.'.jpg')) {
433
            (new ReleaseImage())->saveImage(
434
                $aniDbId,
435
                'http://img7.anidb.net/pics/anime/'.$picture,
436
                $this->imgSavePath
437
            );
438
        }
439
    }
440
}
441