RefreshAnimeData::handle()   F
last analyzed

Complexity

Conditions 39
Paths > 20000

Size

Total Lines 295
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 295
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 App\Services\PopulateAniListService;
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
93
            return self::SUCCESS;
94
        }
95
96
        $this->info("Found {$totalCount} anime releases to process.");
97
98
        if ($limit > 0) {
99
            $releases = $releases->take($limit);
100
            $totalCount = $releases->count();
101
            $this->info("Processing {$totalCount} releases (limited).");
102
        }
103
104
        $this->newLine();
105
106
        $populateAniList = new PopulateAniListService;
107
        $processed = 0;
108
        $successful = 0;
109
        $failed = 0;
110
        $skipped = 0;
111
        $notFound = 0;
112
        $failedSearchnames = []; // Track failed searchnames for summary
113
114
        // Process in chunks
115
        $chunks = $releases->chunk($chunkSize);
116
        $progressBar = $this->output->createProgressBar($totalCount);
117
        $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s% -- %message%');
118
        $progressBar->setMessage('Starting...');
119
        $progressBar->start();
120
121
        foreach ($chunks as $chunk) {
122
            foreach ($chunk as $release) {
123
                $searchname = $release->searchname ?? '';
124
                $progressBar->setMessage('Processing: '.substr($searchname, 0, 50).'...');
125
126
                try {
127
                    // Extract clean title from searchname
128
                    $titleData = $this->extractTitleFromSearchname($searchname);
129
130
                    if (empty($titleData) || empty($titleData['title'])) {
131
                        $notFound++;
132
                        $failedSearchnames[] = [
133
                            'searchname' => $searchname,
134
                            'reason' => 'Failed to extract title',
135
                            'cleaned_title' => null,
136
                        ];
137
                        if ($this->getOutput()->isVerbose()) {
138
                            $this->newLine();
139
                            $this->warn("Failed to extract title from searchname: {$searchname}");
140
                        }
141
                        $processed++;
142
                        $progressBar->advance();
143
144
                        continue;
145
                    }
146
147
                    $cleanTitle = $titleData['title'];
148
149
                    // Check if we should skip (if not forcing and data exists)
150
                    // Don't skip if we're retrying failed releases (anidbid <= 0)
151
                    if (! $force && ! $missingOnly && ! $retryFailed) {
152
                        // Check if release already has complete AniList data
153
                        if ($release->anidbid > 0) {
154
                            $anidbInfo = DB::table('anidb_info')
155
                                ->where('anidbid', $release->anidbid)
156
                                ->whereNotNull('anilist_id')
157
                                ->whereNotNull('country')
158
                                ->whereNotNull('media_type')
159
                                ->first();
160
161
                            if ($anidbInfo) {
162
                                $skipped++;
163
                                $processed++;
164
                                $progressBar->advance();
165
166
                                continue;
167
                            }
168
                        }
169
                    }
170
171
                    // Search AniList for this title (with rate limiting)
172
                    $this->enforceRateLimit();
173
                    $searchResults = $populateAniList->searchAnime($cleanTitle, 1);
174
175
                    if (! $searchResults || empty($searchResults)) {
176
                        // Try with spaces replaced for broader matching
177
                        $altTitle = preg_replace('/\s+/', ' ', $cleanTitle);
178
                        if ($altTitle !== $cleanTitle) {
179
                            $this->enforceRateLimit();
180
                            $searchResults = $populateAniList->searchAnime($altTitle, 1);
181
                        }
182
                    }
183
184
                    if (! $searchResults || empty($searchResults)) {
185
                        $notFound++;
186
                        $failedSearchnames[] = [
187
                            'searchname' => $searchname,
188
                            'reason' => 'No AniList match found',
189
                            'cleaned_title' => $cleanTitle,
190
                        ];
191
                        if ($this->getOutput()->isVerbose()) {
192
                            $this->newLine();
193
                            $this->warn('No AniList match found for:');
194
                            $this->line("  Searchname: {$searchname}");
195
                            $this->line("  Cleaned title: {$cleanTitle}");
196
                        }
197
                        $processed++;
198
                        $progressBar->advance();
199
200
                        continue;
201
                    }
202
203
                    $anilistData = $searchResults[0];
204
                    $anilistId = $anilistData['id'] ?? null;
205
206
                    if (! $anilistId) {
207
                        $notFound++;
208
                        $failedSearchnames[] = [
209
                            'searchname' => $searchname,
210
                            'reason' => 'AniList result missing ID',
211
                            'cleaned_title' => $cleanTitle,
212
                        ];
213
                        if ($this->getOutput()->isVerbose()) {
214
                            $this->newLine();
215
                            $this->warn('AniList search returned result but no ID for:');
216
                            $this->line("  Searchname: {$searchname}");
217
                            $this->line("  Cleaned title: {$cleanTitle}");
218
                        }
219
                        $processed++;
220
                        $progressBar->advance();
221
222
                        continue;
223
                    }
224
225
                    // Fetch full data from AniList and insert/update (with rate limiting)
226
                    // This will create/update anidb_info entry using anilist_id as anidbid if needed
227
                    $this->enforceRateLimit();
228
                    $populateAniList->populateTable('info', $anilistId);
229
230
                    // Get the anidbid that was created/updated (it uses anilist_id as anidbid)
231
                    $anidbid = AnidbInfo::query()
232
                        ->where('anilist_id', $anilistId)
233
                        ->value('anidbid');
234
235
                    if (! $anidbid) {
236
                        // Fallback: use anilist_id as anidbid
237
                        $anidbid = (int) $anilistId;
238
                    }
239
240
                    // Update release with the anidbid
241
                    Release::query()
242
                        ->where('id', $release->id)
243
                        ->update(['anidbid' => $anidbid]);
244
245
                    $successful++;
246
                } catch (\Exception $e) {
247
                    // Check if this is a 429 rate limit error
248
                    if (str_contains($e->getMessage(), '429') || str_contains($e->getMessage(), 'rate limit exceeded')) {
249
                        $this->newLine();
250
                        $this->error('AniList API rate limit exceeded (429). Stopping processing for 15 minutes.');
251
                        $this->warn('Please wait 15 minutes before running this command again.');
252
                        $progressBar->finish();
253
                        $this->newLine();
254
255
                        // Show summary of what was processed before the error
256
                        $this->info('Summary (before rate limit error):');
257
                        $this->table(
258
                            ['Status', 'Count'],
259
                            [
260
                                ['Total Processed', $processed],
261
                                ['Successful', $successful],
262
                                ['Failed', $failed],
263
                                ['Not Found', $notFound],
264
                                ['Skipped', $skipped],
265
                            ]
266
                        );
267
268
                        // Show failed searchnames if any
269
                        if (! empty($failedSearchnames)) {
270
                            $this->newLine();
271
                            $this->warn('Failed searchnames (before rate limit error):');
272
                            $this->line('Showing up to 10 examples:');
273
                            $examples = array_slice($failedSearchnames, 0, 10);
274
                            foreach ($examples as $item) {
275
                                $cleanedTitle = $item['cleaned_title'] ?? '(extraction failed)';
276
                                $this->line("  - {$item['searchname']} -> {$cleanedTitle} ({$item['reason']})");
277
                            }
278
                            if (count($failedSearchnames) > 10) {
279
                                $this->line('  ... and '.(count($failedSearchnames) - 10).' more.');
280
                            }
281
                        }
282
283
                        return self::FAILURE;
284
                    }
285
286
                    $failed++;
287
                    if ($this->getOutput()->isVerbose()) {
288
                        $this->newLine();
289
                        $this->error("Error processing release ID {$release->id}: ".$e->getMessage());
290
                    }
291
                }
292
293
                $processed++;
294
                $progressBar->advance();
295
            }
296
        }
297
298
        $progressBar->setMessage('Complete!');
299
        $progressBar->finish();
300
        $this->newLine(2);
301
302
        // Summary
303
        $this->info('Summary:');
304
        $this->table(
305
            ['Status', 'Count'],
306
            [
307
                ['Total Processed', $processed],
308
                ['Successful', $successful],
309
                ['Failed', $failed],
310
                ['Not Found', $notFound],
311
                ['Skipped', $skipped],
312
            ]
313
        );
314
315
        // Show failed searchnames if any
316
        if (! empty($failedSearchnames) && $notFound > 0) {
317
            $this->newLine();
318
            $this->warn("Failed to fetch data for {$notFound} release(s):");
319
            $this->newLine();
320
321
            // Show up to 20 examples
322
            $examples = array_slice($failedSearchnames, 0, 20);
323
            $rows = [];
324
            foreach ($examples as $item) {
325
                $cleanedTitle = $item['cleaned_title'] ?? '(extraction failed)';
326
                $rows[] = [
327
                    substr($item['searchname'], 0, 60).(strlen($item['searchname']) > 60 ? '...' : ''),
328
                    substr($cleanedTitle, 0, 40).(strlen($cleanedTitle) > 40 ? '...' : ''),
329
                    $item['reason'],
330
                ];
331
            }
332
333
            $this->table(
334
                ['Searchname', 'Cleaned Title', 'Reason'],
335
                $rows
336
            );
337
338
            if (count($failedSearchnames) > 20) {
339
                $this->line('... and '.(count($failedSearchnames) - 20).' more. Use --verbose to see all.');
340
            }
341
        }
342
343
        return self::SUCCESS;
344
    }
345
346
    /**
347
     * Extract clean anime title from release searchname.
348
     * Similar to extractTitleEpisode in AniDB.php but simplified.
349
     *
350
     * @return array{title: string}|array{}
351
     */
352
    private function extractTitleFromSearchname(string $searchname): array
353
    {
354
        if (empty($searchname)) {
355
            return [];
356
        }
357
358
        // Fix UTF-8 encoding issues (double-encoding, corrupted sequences)
359
        $s = $this->fixEncoding($searchname);
360
361
        // Normalize common separators
362
        $s = str_replace(['_', '.'], ' ', $s);
363
        $s = preg_replace('/\s+/', ' ', $s);
364
        $s = trim($s);
365
366
        // Strip leading group tags like [Group]
367
        $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s);
368
        $s = trim($s);
369
370
        // Remove language codes and tags
371
        $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s);
372
        $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s);
373
374
        // Extract title by removing episode patterns
375
        $title = '';
376
377
        // Try to extract title by removing episode patterns
378
        // 1) Look for " S01E01" or " S1E1" pattern
379
        if (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) {
380
            $title = substr($s, 0, (int) $m[0][1]);
381
        }
382
        // 2) Look for " 1x18" or " 2x05" pattern (season x episode)
383
        elseif (preg_match('/\s\d+x\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) {
384
            $title = substr($s, 0, (int) $m[0][1]);
385
        }
386
        // 3) Look for " - NNN" and extract title before it
387
        elseif (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) {
388
            $title = substr($s, 0, (int) $m[0][1]);
389
        }
390
        // 4) If not found, look for " E0*NNN" or " Ep NNN"
391
        elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
392
            $title = substr($s, 0, (int) $m[0][1]);
393
        }
394
        // 4) Keywords Movie/OVA/Complete Series
395
        elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) {
396
            $title = substr($s, 0, (int) $m[0][1]);
397
        }
398
        // 5) BD/resolution releases: pick title before next bracket token
399
        elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) {
400
            $title = substr($s, 0, (int) $m[0][1]);
401
        } else {
402
            // No episode pattern found, use the whole string as title
403
            $title = $s;
404
        }
405
406
        $title = $this->cleanTitle($title);
407
408
        if ($title === '') {
409
            return [];
410
        }
411
412
        return ['title' => $title];
413
    }
414
415
    /**
416
     * Fix UTF-8 encoding issues in strings (double-encoding, corrupted sequences).
417
     */
418
    private function fixEncoding(string $text): string
419
    {
420
        // Remove common corrupted character sequences (encoding artifacts)
421
        // Pattern: âÂ_Â, â Â, âÂ, etc.
422
        $text = preg_replace('/âÂ[_\sÂ]*/u', '', $text);
423
        $text = preg_replace('/Ã[¢Â©€£]/u', '', $text);
424
425
        // Remove standalone  characters (common encoding artifact)
426
        $text = preg_replace('/Â+/u', '', $text);
427
428
        // Remove any remaining à sequences (encoding artifacts)
429
        $text = preg_replace('/Ã[^\s]*/u', '', $text);
430
431
        // Try to detect and fix double-encoding issues
432
        // Common patterns: é, Ã, etc. (UTF-8 interpreted as ISO-8859-1)
433
        if (preg_match('/Ã[^\s]/u', $text)) {
434
            // Try ISO-8859-1 -> UTF-8 conversion (common double-encoding fix)
435
            $converted = @mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1');
436
            if ($converted !== false && ! preg_match('/Ã[^\s]/u', $converted)) {
0 ignored issues
show
Bug introduced by
It seems like $converted can also be of type array; however, parameter $subject of preg_match() 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

436
            if ($converted !== false && ! preg_match('/Ã[^\s]/u', /** @scrutinizer ignore-type */ $converted)) {
Loading history...
437
                $text = $converted;
438
            }
439
        }
440
441
        // Remove any remaining non-printable or control characters except spaces
442
        $text = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $text);
443
444
        // Normalize Unicode (NFD -> NFC) if available
445
        if (function_exists('normalizer_normalize')) {
446
            $text = normalizer_normalize($text, \Normalizer::FORM_C);
447
        }
448
449
        // Final cleanup: remove any remaining isolated non-ASCII control-like characters
450
        // This catches any remaining encoding artifacts
451
        $text = preg_replace('/[\xC0-\xC1\xC2-\xC5]/u', '', $text);
452
453
        return $text;
454
    }
455
456
    /**
457
     * Strip stray separators, language codes, episode numbers, and other release tags from title.
458
     */
459
    private function cleanTitle(string $title): string
460
    {
461
        // Fix encoding issues first
462
        $title = $this->fixEncoding($title);
463
464
        // Remove all bracketed tags (language, quality, etc.)
465
        $title = preg_replace('/\[[^\]]+\]/', ' ', $title);
466
467
        // Remove all parenthesized tags
468
        $title = preg_replace('/\([^)]+\)/', ' ', $title);
469
470
        // Remove language codes (standalone or with separators)
471
        $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);
472
473
        // Remove metadata words (JAV, Uncensored, Censored, etc.)
474
        $title = preg_replace('/\b(JAV|Uncensored|Censored|Mosaic|Mosaic-less|HD|SD|FHD|UHD)\b/i', ' ', $title);
475
476
        // Remove date patterns (6-digit dates like 091919, 200101, etc.)
477
        $title = preg_replace('/\b\d{6}\b/', ' ', $title);
478
479
        // Remove trailing numbers/underscores (like _01, 01, _001, etc.)
480
        $title = preg_replace('/[-_]\s*\d{1,4}\s*$/i', '', $title);
481
        $title = preg_replace('/\s+\d{1,4}\s*$/i', '', $title);
482
483
        // Remove episode patterns (including episode titles that follow)
484
        // Remove " - 1x18 - Episode Title" or " - 1x18" patterns
485
        $title = preg_replace('/\s*-\s*\d+x\d+.*$/i', '', $title);
486
        // Remove " S01E01" or " S1E1" pattern
487
        $title = preg_replace('/\s+S\d+E\d+.*$/i', '', $title);
488
        // Remove " - NNN" or " - NNN - Episode Title" patterns
489
        $title = preg_replace('/\s*-\s*\d{1,4}(?:\s*-\s*.*)?\s*$/i', '', $title);
490
        $title = preg_replace('/\s*-\s*$/i', '', $title);
491
        // Remove " E0*NNN" or " Ep NNN" patterns
492
        $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title);
493
494
        // Remove quality/resolution tags
495
        $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);
496
497
        // Remove common release tags
498
        $title = preg_replace('/\b(PROPER|REPACK|RIP|ISO|CRACK|BETA|ALPHA|FINAL|COMPLETE|FULL)\b/i', ' ', $title);
499
500
        // Remove volume/chapter markers
501
        $title = preg_replace('/\s+Vol\.?\s*\d*\s*$/i', '', $title);
502
        $title = preg_replace('/\s+Ch\.?\s*\d*\s*$/i', '', $title);
503
504
        // Remove trailing dashes and separators
505
        $title = preg_replace('/\s*[-_]\s*$/', '', $title);
506
507
        // Normalize whitespace
508
        $title = preg_replace('/\s+/', ' ', $title);
509
510
        return trim($title);
511
    }
512
513
    /**
514
     * Enforce rate limiting: 35 requests per minute (conservative limit).
515
     * Adds delays between API calls to prevent hitting AniList's 90/min limit.
516
     */
517
    private function enforceRateLimit(): void
518
    {
519
        $now = time();
520
521
        // Clean old timestamps (older than 1 minute)
522
        $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
523
            return ($now - $timestamp) < 60;
524
        });
525
526
        $requestCount = count($this->requestTimestamps);
527
528
        // If we're at or over the limit, wait
529
        if ($requestCount >= self::RATE_LIMIT_PER_MINUTE) {
530
            // Calculate wait time based on oldest request
531
            if (! empty($this->requestTimestamps)) {
532
                $oldestRequest = min($this->requestTimestamps);
533
                $waitTime = 60 - ($now - $oldestRequest) + 1; // +1 for safety margin
534
535
                if ($waitTime > 0 && $waitTime <= 60) {
536
                    if ($this->getOutput()->isVerbose()) {
537
                        $this->newLine();
538
                        $this->warn("Rate limit reached ({$requestCount}/".self::RATE_LIMIT_PER_MINUTE."). Waiting {$waitTime} seconds...");
539
                    }
540
                    sleep($waitTime);
541
542
                    // Clean timestamps again after waiting
543
                    $now = time();
544
                    $this->requestTimestamps = array_filter($this->requestTimestamps, function ($timestamp) use ($now) {
545
                        return ($now - $timestamp) < 60;
546
                    });
547
                }
548
            }
549
        }
550
551
        // Calculate minimum delay between requests (to maintain 20/min rate)
552
        // 60 seconds / 20 requests = 3 seconds per request
553
        $minDelay = 60.0 / self::RATE_LIMIT_PER_MINUTE;
554
555
        // If we have recent requests, ensure we wait at least the minimum delay
556
        if (! empty($this->requestTimestamps)) {
557
            $lastRequest = max($this->requestTimestamps);
558
            $timeSinceLastRequest = $now - $lastRequest;
559
560
            if ($timeSinceLastRequest < $minDelay) {
561
                $waitTime = $minDelay - $timeSinceLastRequest;
562
                if ($waitTime > 0 && $waitTime < 2) { // Only wait if less than 2 seconds
563
                    usleep((int) ($waitTime * 1000000)); // Convert to microseconds
564
                    $now = time(); // Update now after waiting
565
                }
566
            }
567
        }
568
569
        // Record this request timestamp (after all delays)
570
        $this->requestTimestamps[] = $now;
571
    }
572
}
573