Passed
Push — master ( efec1e...53abd1 )
by Darko
10:31
created

RefreshAnimeData::handle()   F

Complexity

Conditions 39
Paths > 20000

Size

Total Lines 290
Code Lines 187

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 187
c 3
b 0
f 0
dl 0
loc 290
rs 0
cc 39
nc 54588
nop 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Console\Commands;
4
5
use App\Models\AnidbInfo;
6
use App\Models\Category;
7
use App\Models\Release;
8
use Blacklight\PopulateAniList;
9
use Illuminate\Console\Command;
10
use Illuminate\Support\Facades\DB;
11
12
class RefreshAnimeData extends Command
13
{
14
    /**
15
     * Conservative rate limit: 20 requests per minute (to stay well below AniList's 90/min limit).
16
     * This allows for multiple API calls per release (search + getById).
17
     */
18
    private const RATE_LIMIT_PER_MINUTE = 20;
19
20
    /**
21
     * Track API request timestamps for rate limiting.
22
     *
23
     * @var array<int>
24
     */
25
    private array $requestTimestamps = [];
26
27
    /**
28
     * The name and signature of the console command.
29
     *
30
     * @var string
31
     */
32
    protected $signature = 'anime:refresh
33
                            {--limit=0 : Maximum number of releases to process (0 = all)}
34
                            {--chunk=100 : Process releases in chunks of this size}
35
                            {--missing-only : Only refresh releases missing AniList data (no anilist_id)}
36
                            {--retry-failed : Only refresh releases with anidbid <= 0 (failed processing: -1, -2, etc.)}
37
                            {--force : Force refresh even if data exists}';
38
39
    /**
40
     * The console command description.
41
     *
42
     * @var string
43
     */
44
    protected $description = 'Fetch and refresh AniList data for existing anime releases in TV->Anime category by matching release searchname';
45
46
    /**
47
     * Execute the console command.
48
     */
49
    public function handle(): int
50
    {
51
        $limit = (int) $this->option('limit');
52
        $chunkSize = (int) $this->option('chunk');
53
        $missingOnly = $this->option('missing-only');
54
        $retryFailed = $this->option('retry-failed');
55
        $force = $this->option('force');
56
57
        $this->info('Starting AniList data refresh for anime releases...');
58
        if ($retryFailed) {
59
            $this->info('Mode: Retrying failed releases (anidbid <= 0)...');
60
        } elseif ($missingOnly) {
61
            $this->info('Mode: Missing AniList data only...');
62
        } else {
63
            $this->info('Mode: All releases...');
64
        }
65
        $this->info('Matching releases by searchname to AniList API...');
66
        $this->newLine();
67
68
        // Build query for releases in TV_ANIME category
69
        $query = Release::query()
70
            ->select(['releases.id', 'releases.anidbid', 'releases.searchname'])
71
            ->where('categories_id', Category::TV_ANIME);
72
73
        // If retry-failed, only get releases with anidbid <= 0 (failed processing)
74
        if ($retryFailed) {
75
            $query->where('releases.anidbid', '<=', 0);
76
        }
77
78
        // If missing-only, only get releases without anilist_id
79
        if ($missingOnly) {
80
            $query->leftJoin('anidb_info as ai', 'ai.anidbid', '=', 'releases.anidbid')
81
                ->whereNull('ai.anilist_id');
82
        }
83
84
        // Get releases (not distinct anidbids, since we're matching by searchname)
85
        $releases = $query->orderBy('releases.id')
0 ignored issues
show
Bug introduced by
'releases.id' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

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

85
        $releases = $query->orderBy(/** @scrutinizer ignore-type */ 'releases.id')
Loading history...
86
            ->get();
87
88
        $totalCount = $releases->count();
89
90
        if ($totalCount === 0) {
91
            $this->warn('No anime releases found to process.');
92
            return self::SUCCESS;
93
        }
94
95
        $this->info("Found {$totalCount} anime releases to process.");
96
        
97
        if ($limit > 0) {
98
            $releases = $releases->take($limit);
99
            $totalCount = $releases->count();
100
            $this->info("Processing {$totalCount} releases (limited).");
101
        }
102
103
        $this->newLine();
104
105
        $populateAniList = new PopulateAniList;
106
        $processed = 0;
107
        $successful = 0;
108
        $failed = 0;
109
        $skipped = 0;
110
        $notFound = 0;
111
        $failedSearchnames = []; // Track failed searchnames for summary
112
113
        // Process in chunks
114
        $chunks = $releases->chunk($chunkSize);
115
        $progressBar = $this->output->createProgressBar($totalCount);
116
        $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s% -- %message%');
117
        $progressBar->setMessage('Starting...');
118
        $progressBar->start();
119
120
        foreach ($chunks as $chunk) {
121
            foreach ($chunk as $release) {
122
                $searchname = $release->searchname ?? '';
123
                $progressBar->setMessage("Processing: " . substr($searchname, 0, 50) . "...");
124
125
                try {
126
                    // Extract clean title from searchname
127
                    $titleData = $this->extractTitleFromSearchname($searchname);
128
                    
129
                    if (empty($titleData) || empty($titleData['title'])) {
130
                        $notFound++;
131
                        $failedSearchnames[] = [
132
                            'searchname' => $searchname,
133
                            'reason' => 'Failed to extract title',
134
                            'cleaned_title' => null,
135
                        ];
136
                        if ($this->getOutput()->isVerbose()) {
137
                            $this->newLine();
138
                            $this->warn("Failed to extract title from searchname: {$searchname}");
139
                        }
140
                        $processed++;
141
                        $progressBar->advance();
142
                        continue;
143
                    }
144
145
                    $cleanTitle = $titleData['title'];
146
147
                    // Check if we should skip (if not forcing and data exists)
148
                    // Don't skip if we're retrying failed releases (anidbid <= 0)
149
                    if (! $force && ! $missingOnly && ! $retryFailed) {
150
                        // Check if release already has complete AniList data
151
                        if ($release->anidbid > 0) {
152
                            $anidbInfo = DB::table('anidb_info')
153
                                ->where('anidbid', $release->anidbid)
154
                                ->whereNotNull('anilist_id')
155
                                ->whereNotNull('country')
156
                                ->whereNotNull('media_type')
157
                                ->first();
158
159
                            if ($anidbInfo) {
160
                                $skipped++;
161
                                $processed++;
162
                                $progressBar->advance();
163
                                continue;
164
                            }
165
                        }
166
                    }
167
168
                    // Search AniList for this title (with rate limiting)
169
                    $this->enforceRateLimit();
170
                    $searchResults = $populateAniList->searchAnime($cleanTitle, 1);
171
                    
172
                    if (! $searchResults || empty($searchResults)) {
173
                        // Try with spaces replaced for broader matching
174
                        $altTitle = preg_replace('/\s+/', ' ', $cleanTitle);
175
                        if ($altTitle !== $cleanTitle) {
176
                            $this->enforceRateLimit();
177
                            $searchResults = $populateAniList->searchAnime($altTitle, 1);
178
                        }
179
                    }
180
181
                    if (! $searchResults || empty($searchResults)) {
182
                        $notFound++;
183
                        $failedSearchnames[] = [
184
                            'searchname' => $searchname,
185
                            'reason' => 'No AniList match found',
186
                            'cleaned_title' => $cleanTitle,
187
                        ];
188
                        if ($this->getOutput()->isVerbose()) {
189
                            $this->newLine();
190
                            $this->warn("No AniList match found for:");
191
                            $this->line("  Searchname: {$searchname}");
192
                            $this->line("  Cleaned title: {$cleanTitle}");
193
                        }
194
                        $processed++;
195
                        $progressBar->advance();
196
                        continue;
197
                    }
198
199
                    $anilistData = $searchResults[0];
200
                    $anilistId = $anilistData['id'] ?? null;
201
202
                    if (! $anilistId) {
203
                        $notFound++;
204
                        $failedSearchnames[] = [
205
                            'searchname' => $searchname,
206
                            'reason' => 'AniList result missing ID',
207
                            'cleaned_title' => $cleanTitle,
208
                        ];
209
                        if ($this->getOutput()->isVerbose()) {
210
                            $this->newLine();
211
                            $this->warn("AniList search returned result but no ID for:");
212
                            $this->line("  Searchname: {$searchname}");
213
                            $this->line("  Cleaned title: {$cleanTitle}");
214
                        }
215
                        $processed++;
216
                        $progressBar->advance();
217
                        continue;
218
                    }
219
220
                    // Fetch full data from AniList and insert/update (with rate limiting)
221
                    // This will create/update anidb_info entry using anilist_id as anidbid if needed
222
                    $this->enforceRateLimit();
223
                    $populateAniList->populateTable('info', $anilistId);
224
225
                    // Get the anidbid that was created/updated (it uses anilist_id as anidbid)
226
                    $anidbid = AnidbInfo::query()
227
                        ->where('anilist_id', $anilistId)
228
                        ->value('anidbid');
229
230
                    if (! $anidbid) {
231
                        // Fallback: use anilist_id as anidbid
232
                        $anidbid = (int) $anilistId;
233
                    }
234
235
                    // Update release with the anidbid
236
                    Release::query()
237
                        ->where('id', $release->id)
238
                        ->update(['anidbid' => $anidbid]);
239
240
                    $successful++;
241
                } catch (\Exception $e) {
242
                    // Check if this is a 429 rate limit error
243
                    if (str_contains($e->getMessage(), '429') || str_contains($e->getMessage(), 'rate limit exceeded')) {
244
                        $this->newLine();
245
                        $this->error('AniList API rate limit exceeded (429). Stopping processing for 15 minutes.');
246
                        $this->warn('Please wait 15 minutes before running this command again.');
247
                        $progressBar->finish();
248
                        $this->newLine();
249
                        
250
                        // Show summary of what was processed before the error
251
                        $this->info('Summary (before rate limit error):');
252
                        $this->table(
253
                            ['Status', 'Count'],
254
                            [
255
                                ['Total Processed', $processed],
256
                                ['Successful', $successful],
257
                                ['Failed', $failed],
258
                                ['Not Found', $notFound],
259
                                ['Skipped', $skipped],
260
                            ]
261
                        );
262
                        
263
                        // Show failed searchnames if any
264
                        if (!empty($failedSearchnames)) {
265
                            $this->newLine();
266
                            $this->warn("Failed searchnames (before rate limit error):");
267
                            $this->line("Showing up to 10 examples:");
268
                            $examples = array_slice($failedSearchnames, 0, 10);
269
                            foreach ($examples as $item) {
270
                                $cleanedTitle = $item['cleaned_title'] ?? '(extraction failed)';
271
                                $this->line("  - {$item['searchname']} -> {$cleanedTitle} ({$item['reason']})");
272
                            }
273
                            if (count($failedSearchnames) > 10) {
274
                                $this->line("  ... and " . (count($failedSearchnames) - 10) . " more.");
275
                            }
276
                        }
277
                        
278
                        return self::FAILURE;
279
                    }
280
                    
281
                    $failed++;
282
                    if ($this->getOutput()->isVerbose()) {
283
                        $this->newLine();
284
                        $this->error("Error processing release ID {$release->id}: " . $e->getMessage());
285
                    }
286
                }
287
288
                $processed++;
289
                $progressBar->advance();
290
            }
291
        }
292
293
        $progressBar->setMessage('Complete!');
294
        $progressBar->finish();
295
        $this->newLine(2);
296
297
        // Summary
298
        $this->info('Summary:');
299
        $this->table(
300
            ['Status', 'Count'],
301
            [
302
                ['Total Processed', $processed],
303
                ['Successful', $successful],
304
                ['Failed', $failed],
305
                ['Not Found', $notFound],
306
                ['Skipped', $skipped],
307
            ]
308
        );
309
310
        // Show failed searchnames if any
311
        if (!empty($failedSearchnames) && $notFound > 0) {
312
            $this->newLine();
313
            $this->warn("Failed to fetch data for {$notFound} release(s):");
314
            $this->newLine();
315
            
316
            // Show up to 20 examples
317
            $examples = array_slice($failedSearchnames, 0, 20);
318
            $rows = [];
319
            foreach ($examples as $item) {
320
                $cleanedTitle = $item['cleaned_title'] ?? '(extraction failed)';
321
                $rows[] = [
322
                    substr($item['searchname'], 0, 60) . (strlen($item['searchname']) > 60 ? '...' : ''),
323
                    substr($cleanedTitle, 0, 40) . (strlen($cleanedTitle) > 40 ? '...' : ''),
324
                    $item['reason'],
325
                ];
326
            }
327
            
328
            $this->table(
329
                ['Searchname', 'Cleaned Title', 'Reason'],
330
                $rows
331
            );
332
            
333
            if (count($failedSearchnames) > 20) {
334
                $this->line("... and " . (count($failedSearchnames) - 20) . " more. Use --verbose to see all.");
335
            }
336
        }
337
338
        return self::SUCCESS;
339
    }
340
341
    /**
342
     * Extract clean anime title from release searchname.
343
     * Similar to extractTitleEpisode in AniDB.php but simplified.
344
     *
345
     * @return array{title: string}|array{}
346
     */
347
    private function extractTitleFromSearchname(string $searchname): array
348
    {
349
        if (empty($searchname)) {
350
            return [];
351
        }
352
353
        // Normalize common separators
354
        $s = str_replace(['_', '.'], ' ', $searchname);
355
        $s = preg_replace('/\s+/', ' ', $s);
356
        $s = trim($s);
357
358
        // Strip leading group tags like [Group]
359
        $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s);
360
        $s = trim($s);
361
362
        // Remove language codes and tags
363
        $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s);
364
        $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s);
365
        
366
        // Extract title by removing episode patterns
367
        $title = '';
368
369
        // Try to extract title by removing episode patterns
370
        // 1) Look for " S01E01" or " S1E1" pattern
371
        if (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) {
372
            $title = substr($s, 0, (int) $m[0][1]);
373
        }
374
        // 2) Look for " 1x18" or " 2x05" pattern (season x episode)
375
        elseif (preg_match('/\s\d+x\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) {
376
            $title = substr($s, 0, (int) $m[0][1]);
377
        }
378
        // 3) Look for " - NNN" and extract title before it
379
        elseif (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) {
380
            $title = substr($s, 0, (int) $m[0][1]);
381
        }
382
        // 4) If not found, look for " E0*NNN" or " Ep NNN"
383
        elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
384
            $title = substr($s, 0, (int) $m[0][1]);
385
        }
386
        // 4) Keywords Movie/OVA/Complete Series
387
        elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
388
            $title = substr($s, 0, (int) $m[0][1]);
389
        }
390
        // 5) BD/resolution releases: pick title before next bracket token
391
        elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) {
392
            $title = substr($s, 0, (int) $m[0][1]);
393
        } else {
394
            // No episode pattern found, use the whole string as title
395
            $title = $s;
396
        }
397
398
        $title = $this->cleanTitle($title);
399
400
        if ($title === '') {
401
            return [];
402
        }
403
404
        return ['title' => $title];
405
    }
406
407
    /**
408
     * Strip stray separators, language codes, episode numbers, and other release tags from title.
409
     */
410
    private function cleanTitle(string $title): string
411
    {
412
        // Remove all bracketed tags (language, quality, etc.)
413
        $title = preg_replace('/\[[^\]]+\]/', ' ', $title);
414
        
415
        // Remove all parenthesized tags
416
        $title = preg_replace('/\([^)]+\)/', ' ', $title);
417
        
418
        // Remove language codes (standalone or with separators)
419
        $title = preg_replace('/\b(ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\b/i', ' ', $title);
420
        
421
        // Remove metadata words (JAV, Uncensored, Censored, etc.)
422
        $title = preg_replace('/\b(JAV|Uncensored|Censored|Mosaic|Mosaic-less|HD|SD|FHD|UHD)\b/i', ' ', $title);
423
        
424
        // Remove date patterns (6-digit dates like 091919, 200101, etc.)
425
        $title = preg_replace('/\b\d{6}\b/', ' ', $title);
426
        
427
        // Remove trailing numbers/underscores (like _01, 01, _001, etc.)
428
        $title = preg_replace('/[-_]\s*\d{1,4}\s*$/i', '', $title);
429
        $title = preg_replace('/\s+\d{1,4}\s*$/i', '', $title);
430
        
431
        // Remove episode patterns (including episode titles that follow)
432
        // Remove " - 1x18 - Episode Title" or " - 1x18" patterns
433
        $title = preg_replace('/\s*-\s*\d+x\d+.*$/i', '', $title);
434
        // Remove " S01E01" or " S1E1" pattern
435
        $title = preg_replace('/\s+S\d+E\d+.*$/i', '', $title);
436
        // Remove " - NNN" or " - NNN - Episode Title" patterns
437
        $title = preg_replace('/\s*-\s*\d{1,4}(?:\s*-\s*.*)?\s*$/i', '', $title);
438
        $title = preg_replace('/\s*-\s*$/i', '', $title);
439
        // Remove " E0*NNN" or " Ep NNN" patterns
440
        $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title);
441
        
442
        // Remove quality/resolution tags
443
        $title = preg_replace('/\b(480p|720p|1080p|2160p|4K|BD|BDRip|BluRay|Blu-Ray|HEVC|x264|x265|H264|H265|WEB|WEBRip|DVDRip|TVRip)\b/i', ' ', $title);
444
        
445
        // Remove common release tags
446
        $title = preg_replace('/\b(PROPER|REPACK|RIP|ISO|CRACK|BETA|ALPHA|FINAL|COMPLETE|FULL)\b/i', ' ', $title);
447
        
448
        // Remove volume/chapter markers
449
        $title = preg_replace('/\s+Vol\.?\s*\d*\s*$/i', '', $title);
450
        $title = preg_replace('/\s+Ch\.?\s*\d*\s*$/i', '', $title);
451
        
452
        // Remove trailing dashes and separators
453
        $title = preg_replace('/\s*[-_]\s*$/', '', $title);
454
        
455
        // Normalize whitespace
456
        $title = preg_replace('/\s+/', ' ', $title);
457
        
458
        return trim($title);
459
    }
460
461
    /**
462
     * Enforce rate limiting: 35 requests per minute (conservative limit).
463
     * Adds delays between API calls to prevent hitting AniList's 90/min limit.
464
     */
465
    private function enforceRateLimit(): void
466
    {
467
        $now = time();
468
        
469
        // Clean old timestamps (older than 1 minute)
470
        $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
471
            return ($now - $timestamp) < 60;
472
        });
473
474
        $requestCount = count($this->requestTimestamps);
475
476
        // If we're at or over the limit, wait
477
        if ($requestCount >= self::RATE_LIMIT_PER_MINUTE) {
478
            // Calculate wait time based on oldest request
479
            if (! empty($this->requestTimestamps)) {
480
                $oldestRequest = min($this->requestTimestamps);
481
                $waitTime = 60 - ($now - $oldestRequest) + 1; // +1 for safety margin
482
483
                if ($waitTime > 0 && $waitTime <= 60) {
484
                    if ($this->getOutput()->isVerbose()) {
485
                        $this->newLine();
486
                        $this->warn("Rate limit reached ({$requestCount}/" . self::RATE_LIMIT_PER_MINUTE . "). Waiting {$waitTime} seconds...");
487
                    }
488
                    sleep($waitTime);
489
                    
490
                    // Clean timestamps again after waiting
491
                    $now = time();
492
                    $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
493
                        return ($now - $timestamp) < 60;
494
                    });
495
                }
496
            }
497
        }
498
499
        // Calculate minimum delay between requests (to maintain 20/min rate)
500
        // 60 seconds / 20 requests = 3 seconds per request
501
        $minDelay = 60.0 / self::RATE_LIMIT_PER_MINUTE;
502
        
503
        // If we have recent requests, ensure we wait at least the minimum delay
504
        if (! empty($this->requestTimestamps)) {
505
            $lastRequest = max($this->requestTimestamps);
506
            $timeSinceLastRequest = $now - $lastRequest;
507
            
508
            if ($timeSinceLastRequest < $minDelay) {
509
                $waitTime = $minDelay - $timeSinceLastRequest;
510
                if ($waitTime > 0 && $waitTime < 2) { // Only wait if less than 2 seconds
511
                    usleep((int) ($waitTime * 1000000)); // Convert to microseconds
512
                    $now = time(); // Update now after waiting
513
                }
514
            }
515
        }
516
517
        // Record this request timestamp (after all delays)
518
        $this->requestTimestamps[] = $now;
519
    }
520
}
521
522