Passed
Push — master ( 5e2855...c86289 )
by Darko
09:54 queued 03:37
created

AniDB::populateTable()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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

125
                    $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...
126
                    $episodeArray[$i]['episode_no'] = (int) $episode->epno;
127
                    $episodeArray[$i]['airdate'] = (string) $episode->airdate;
128
129
                    if (! empty($episode->title)) {
130
                        foreach ($episode->title as $title) {
131
                            $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

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

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

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

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