Passed
Push — master ( cd5352...93caf0 )
by Darko
10:48
created

RefreshAnimeData::enforceRateLimit()   B

Complexity

Conditions 10
Paths 20

Size

Total Lines 54
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 26
c 1
b 0
f 0
dl 0
loc 54
rs 7.6666
cc 10
nc 20
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: 35 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 = 35;
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
                            {--force : Force refresh even if data exists}';
37
38
    /**
39
     * The console command description.
40
     *
41
     * @var string
42
     */
43
    protected $description = 'Fetch and refresh AniList data for existing anime releases in TV->Anime category by matching release searchname';
44
45
    /**
46
     * Execute the console command.
47
     */
48
    public function handle(): int
49
    {
50
        $limit = (int) $this->option('limit');
51
        $chunkSize = (int) $this->option('chunk');
52
        $missingOnly = $this->option('missing-only');
53
        $force = $this->option('force');
54
55
        $this->info('Starting AniList data refresh for anime releases...');
56
        $this->info('Matching releases by searchname to AniList API...');
57
        $this->newLine();
58
59
        // Build query for releases in TV_ANIME category
60
        $query = Release::query()
61
            ->select(['releases.id', 'releases.anidbid', 'releases.searchname'])
62
            ->where('categories_id', Category::TV_ANIME);
63
64
        // If missing-only, only get releases without anilist_id
65
        if ($missingOnly) {
66
            $query->leftJoin('anidb_info as ai', 'ai.anidbid', '=', 'releases.anidbid')
67
                ->whereNull('ai.anilist_id');
68
        }
69
70
        // Get releases (not distinct anidbids, since we're matching by searchname)
71
        $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

71
        $releases = $query->orderBy(/** @scrutinizer ignore-type */ 'releases.id')
Loading history...
72
            ->get();
73
74
        $totalCount = $releases->count();
75
76
        if ($totalCount === 0) {
77
            $this->warn('No anime releases found to process.');
78
            return self::SUCCESS;
79
        }
80
81
        $this->info("Found {$totalCount} anime releases to process.");
82
        
83
        if ($limit > 0) {
84
            $releases = $releases->take($limit);
85
            $totalCount = $releases->count();
86
            $this->info("Processing {$totalCount} releases (limited).");
87
        }
88
89
        $this->newLine();
90
91
        $populateAniList = new PopulateAniList;
92
        $processed = 0;
93
        $successful = 0;
94
        $failed = 0;
95
        $skipped = 0;
96
        $notFound = 0;
97
98
        // Process in chunks
99
        $chunks = $releases->chunk($chunkSize);
100
        $progressBar = $this->output->createProgressBar($totalCount);
101
        $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s% -- %message%');
102
        $progressBar->setMessage('Starting...');
103
        $progressBar->start();
104
105
        foreach ($chunks as $chunk) {
106
            foreach ($chunk as $release) {
107
                $searchname = $release->searchname ?? '';
108
                $progressBar->setMessage("Processing: " . substr($searchname, 0, 50) . "...");
109
110
                try {
111
                    // Extract clean title from searchname
112
                    $titleData = $this->extractTitleFromSearchname($searchname);
113
                    
114
                    if (empty($titleData) || empty($titleData['title'])) {
115
                        $notFound++;
116
                        $processed++;
117
                        $progressBar->advance();
118
                        continue;
119
                    }
120
121
                    $cleanTitle = $titleData['title'];
122
123
                    // Check if we should skip (if not forcing and data exists)
124
                    if (! $force && ! $missingOnly) {
125
                        // Check if release already has complete AniList data
126
                        if ($release->anidbid > 0) {
127
                            $anidbInfo = DB::table('anidb_info')
128
                                ->where('anidbid', $release->anidbid)
129
                                ->whereNotNull('anilist_id')
130
                                ->whereNotNull('country')
131
                                ->whereNotNull('media_type')
132
                                ->first();
133
134
                            if ($anidbInfo) {
135
                                $skipped++;
136
                                $processed++;
137
                                $progressBar->advance();
138
                                continue;
139
                            }
140
                        }
141
                    }
142
143
                    // Search AniList for this title (with rate limiting)
144
                    $this->enforceRateLimit();
145
                    $searchResults = $populateAniList->searchAnime($cleanTitle, 1);
146
                    
147
                    if (! $searchResults || empty($searchResults)) {
148
                        // Try with spaces replaced for broader matching
149
                        $altTitle = preg_replace('/\s+/', ' ', $cleanTitle);
150
                        if ($altTitle !== $cleanTitle) {
151
                            $this->enforceRateLimit();
152
                            $searchResults = $populateAniList->searchAnime($altTitle, 1);
153
                        }
154
                    }
155
156
                    if (! $searchResults || empty($searchResults)) {
157
                        $notFound++;
158
                        $processed++;
159
                        $progressBar->advance();
160
                        continue;
161
                    }
162
163
                    $anilistData = $searchResults[0];
164
                    $anilistId = $anilistData['id'] ?? null;
165
166
                    if (! $anilistId) {
167
                        $notFound++;
168
                        $processed++;
169
                        $progressBar->advance();
170
                        continue;
171
                    }
172
173
                    // Fetch full data from AniList and insert/update (with rate limiting)
174
                    // This will create/update anidb_info entry using anilist_id as anidbid if needed
175
                    $this->enforceRateLimit();
176
                    $populateAniList->populateTable('info', $anilistId);
177
178
                    // Get the anidbid that was created/updated (it uses anilist_id as anidbid)
179
                    $anidbid = AnidbInfo::query()
180
                        ->where('anilist_id', $anilistId)
181
                        ->value('anidbid');
182
183
                    if (! $anidbid) {
184
                        // Fallback: use anilist_id as anidbid
185
                        $anidbid = (int) $anilistId;
186
                    }
187
188
                    // Update release with the anidbid
189
                    Release::query()
190
                        ->where('id', $release->id)
191
                        ->update(['anidbid' => $anidbid]);
192
193
                    $successful++;
194
                } catch (\Exception $e) {
195
                    // Check if this is a 429 rate limit error
196
                    if (str_contains($e->getMessage(), '429') || str_contains($e->getMessage(), 'rate limit exceeded')) {
197
                        $this->newLine();
198
                        $this->error('AniList API rate limit exceeded (429). Stopping processing for 15 minutes.');
199
                        $this->warn('Please wait 15 minutes before running this command again.');
200
                        $progressBar->finish();
201
                        $this->newLine();
202
                        
203
                        // Show summary of what was processed before the error
204
                        $this->info('Summary (before rate limit error):');
205
                        $this->table(
206
                            ['Status', 'Count'],
207
                            [
208
                                ['Total Processed', $processed],
209
                                ['Successful', $successful],
210
                                ['Failed', $failed],
211
                                ['Not Found', $notFound],
212
                                ['Skipped', $skipped],
213
                            ]
214
                        );
215
                        
216
                        return self::FAILURE;
217
                    }
218
                    
219
                    $failed++;
220
                    if ($this->getOutput()->isVerbose()) {
221
                        $this->newLine();
222
                        $this->error("Error processing release ID {$release->id}: " . $e->getMessage());
223
                    }
224
                }
225
226
                $processed++;
227
                $progressBar->advance();
228
            }
229
        }
230
231
        $progressBar->setMessage('Complete!');
232
        $progressBar->finish();
233
        $this->newLine(2);
234
235
        // Summary
236
        $this->info('Summary:');
237
        $this->table(
238
            ['Status', 'Count'],
239
            [
240
                ['Total Processed', $processed],
241
                ['Successful', $successful],
242
                ['Failed', $failed],
243
                ['Not Found', $notFound],
244
                ['Skipped', $skipped],
245
            ]
246
        );
247
248
        return self::SUCCESS;
249
    }
250
251
    /**
252
     * Extract clean anime title from release searchname.
253
     * Similar to extractTitleEpisode in AniDB.php but simplified.
254
     *
255
     * @return array{title: string}|array{}
256
     */
257
    private function extractTitleFromSearchname(string $searchname): array
258
    {
259
        if (empty($searchname)) {
260
            return [];
261
        }
262
263
        // Normalize common separators
264
        $s = str_replace(['_', '.'], ' ', $searchname);
265
        $s = preg_replace('/\s+/', ' ', $s);
266
        $s = trim($s);
267
268
        // Strip leading group tags like [Group]
269
        $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s);
270
        $s = trim($s);
271
272
        // Remove language codes and tags
273
        $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s);
274
        $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s);
275
        
276
        // Extract title by removing episode patterns
277
        $title = '';
278
279
        // Try to extract title by removing episode patterns
280
        // 1) Look for " - NNN" and extract title before it
281
        if (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) {
282
            $title = substr($s, 0, (int) $m[0][1]);
283
        }
284
        // 2) If not found, look for " E0*NNN" or " Ep NNN"
285
        elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
286
            $title = substr($s, 0, (int) $m[0][1]);
287
        }
288
        // 3) Look for " S01E01" or " S1E1" pattern
289
        elseif (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) {
290
            $title = substr($s, 0, (int) $m[0][1]);
291
        }
292
        // 4) Keywords Movie/OVA/Complete Series
293
        elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
294
            $title = substr($s, 0, (int) $m[0][1]);
295
        }
296
        // 5) BD/resolution releases: pick title before next bracket token
297
        elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) {
298
            $title = substr($s, 0, (int) $m[0][1]);
299
        } else {
300
            // No episode pattern found, use the whole string as title
301
            $title = $s;
302
        }
303
304
        $title = $this->cleanTitle($title);
305
306
        if ($title === '') {
307
            return [];
308
        }
309
310
        return ['title' => $title];
311
    }
312
313
    /**
314
     * Strip stray separators, language codes, episode numbers, and other release tags from title.
315
     */
316
    private function cleanTitle(string $title): string
317
    {
318
        // Remove all bracketed tags (language, quality, etc.)
319
        $title = preg_replace('/\[[^\]]+\]/', ' ', $title);
320
        
321
        // Remove all parenthesized tags
322
        $title = preg_replace('/\([^)]+\)/', ' ', $title);
323
        
324
        // Remove language codes (standalone or with separators)
325
        $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);
326
        
327
        // Remove episode patterns
328
        $title = preg_replace('/\s*-\s*\d{1,4}\s*$/i', '', $title);
329
        $title = preg_replace('/\s*-\s*$/i', '', $title);
330
        $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title);
331
        $title = preg_replace('/\s+S\d+E\d+\s*$/i', '', $title);
332
        
333
        // Remove quality/resolution tags
334
        $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);
335
        
336
        // Remove common release tags
337
        $title = preg_replace('/\b(PROPER|REPACK|RIP|ISO|CRACK|BETA|ALPHA|FINAL|COMPLETE|FULL)\b/i', ' ', $title);
338
        
339
        // Remove volume/chapter markers
340
        $title = preg_replace('/\s+Vol\.?\s*\d*\s*$/i', '', $title);
341
        $title = preg_replace('/\s+Ch\.?\s*\d*\s*$/i', '', $title);
342
        
343
        // Remove trailing dashes and separators
344
        $title = preg_replace('/\s*[-_]\s*$/', '', $title);
345
        
346
        // Normalize whitespace
347
        $title = preg_replace('/\s+/', ' ', $title);
348
        
349
        return trim($title);
350
    }
351
352
    /**
353
     * Enforce rate limiting: 35 requests per minute (conservative limit).
354
     * Adds delays between API calls to prevent hitting AniList's 90/min limit.
355
     */
356
    private function enforceRateLimit(): void
357
    {
358
        $now = time();
359
        
360
        // Clean old timestamps (older than 1 minute)
361
        $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
362
            return ($now - $timestamp) < 60;
363
        });
364
365
        $requestCount = count($this->requestTimestamps);
366
367
        // If we're at or over the limit, wait
368
        if ($requestCount >= self::RATE_LIMIT_PER_MINUTE) {
369
            // Calculate wait time based on oldest request
370
            if (! empty($this->requestTimestamps)) {
371
                $oldestRequest = min($this->requestTimestamps);
372
                $waitTime = 60 - ($now - $oldestRequest) + 1; // +1 for safety margin
373
374
                if ($waitTime > 0 && $waitTime <= 60) {
375
                    if ($this->getOutput()->isVerbose()) {
376
                        $this->newLine();
377
                        $this->warn("Rate limit reached ({$requestCount}/" . self::RATE_LIMIT_PER_MINUTE . "). Waiting {$waitTime} seconds...");
378
                    }
379
                    sleep($waitTime);
380
                    
381
                    // Clean timestamps again after waiting
382
                    $now = time();
383
                    $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
384
                        return ($now - $timestamp) < 60;
385
                    });
386
                }
387
            }
388
        }
389
390
        // Calculate minimum delay between requests (to maintain 35/min rate)
391
        // 60 seconds / 35 requests = ~1.71 seconds per request
392
        $minDelay = 60.0 / self::RATE_LIMIT_PER_MINUTE;
393
        
394
        // If we have recent requests, ensure we wait at least the minimum delay
395
        if (! empty($this->requestTimestamps)) {
396
            $lastRequest = max($this->requestTimestamps);
397
            $timeSinceLastRequest = $now - $lastRequest;
398
            
399
            if ($timeSinceLastRequest < $minDelay) {
400
                $waitTime = $minDelay - $timeSinceLastRequest;
401
                if ($waitTime > 0 && $waitTime < 2) { // Only wait if less than 2 seconds
402
                    usleep((int) ($waitTime * 1000000)); // Convert to microseconds
403
                    $now = time(); // Update now after waiting
404
                }
405
            }
406
        }
407
408
        // Record this request timestamp (after all delays)
409
        $this->requestTimestamps[] = $now;
410
    }
411
}
412
413