Issues (864)

app/Services/SteamService.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Services;
6
7
use App\Models\SteamApp;
8
use Illuminate\Support\Arr;
9
use Illuminate\Support\Carbon;
10
use Illuminate\Support\Collection;
11
use Illuminate\Support\Facades\Cache;
12
use Illuminate\Support\Facades\Http;
13
use Illuminate\Support\Facades\Log;
14
use Illuminate\Support\Facades\RateLimiter;
15
16
/**
17
 * SteamService - Comprehensive Steam API integration for PC Games.
18
 *
19
 * Features:
20
 * - Full Steam Store API integration
21
 * - Rate limiting and caching
22
 * - Robust title matching with fuzzy search
23
 * - Complete game metadata retrieval
24
 * - DLC and package information
25
 */
26
class SteamService
27
{
28
    // Steam API endpoints
29
    protected const string STEAM_API_BASE = 'https://api.steampowered.com';
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 29 at column 27
Loading history...
30
    protected const string STEAM_STORE_BASE = 'https://store.steampowered.com/api';
31
    protected const string STEAM_STORE_URL = 'https://store.steampowered.com/app/';
32
    protected const string STEAM_CDN_BASE = 'https://cdn.akamai.steamstatic.com/steam/apps';
33
34
    // Rate limiting
35
    protected const string RATE_LIMIT_KEY = 'steam_api_rate_limit';
36
    protected const int REQUESTS_PER_MINUTE = 200; // Steam allows ~200 requests per 5 minutes
37
    protected const int DECAY_SECONDS = 60;
38
39
    // Cache TTLs
40
    protected const int APP_DETAILS_CACHE_TTL = 86400; // 24 hours
41
    protected const int APP_LIST_CACHE_TTL = 604800; // 7 days
42
    protected const int SEARCH_CACHE_TTL = 3600; // 1 hour
43
    protected const int FAILED_LOOKUP_CACHE_TTL = 1800; // 30 minutes
44
45
    // Matching configuration
46
    protected const int MATCH_THRESHOLD = 85;
47
    protected const int RELAXED_MATCH_THRESHOLD = 75;
48
49
    // Scene/release group noise patterns
50
    protected const array SCENE_GROUPS = [
51
        'CODEX', 'PLAZA', 'GOG', 'CPY', 'HOODLUM', 'EMPRESS', 'RUNE', 'TENOKE', 'FLT',
52
        'RELOADED', 'SKIDROW', 'PROPHET', 'RAZOR1911', 'CORE', 'REFLEX', 'P2P', 'GOLDBERG',
53
        'DARKSIDERS', 'TINYISO', 'DOGE', 'ANOMALY', 'ELAMIGOS', 'FITGIRL', 'DODI', 'XATAB',
54
        'CHRONOS', 'FCKDRM', 'I_KNOW', 'KAOS', 'SIMPLEX', 'ALI213', 'FLTDOX', '3DMGAME',
55
        'POSTMORTEM', 'VACE', 'ROGUE', 'OUTLAWS', 'DARKSIIDERS', 'ONLINEFIX',
56
    ];
57
58
    protected const array EDITION_TAGS = [
59
        'GOTY', 'GAME OF THE YEAR', 'DEFINITIVE EDITION', 'DELUXE EDITION', 'ULTIMATE EDITION',
60
        'COMPLETE EDITION', 'REMASTERED', 'HD REMASTER', 'DIRECTORS CUT', 'ANNIVERSARY EDITION',
61
        'ENHANCED EDITION', 'SPECIAL EDITION', 'COLLECTORS EDITION', 'GOLD EDITION',
62
        'PREMIUM EDITION', 'LEGENDARY EDITION', 'STANDARD EDITION', 'DIGITAL EDITION',
63
    ];
64
65
    protected const array RELEASE_TAGS = [
66
        'REPACK', 'RIP', 'ISO', 'PROPER', 'UPDATE', 'DLC', 'INCL', 'MULTI', 'CRACK',
67
        'CRACKFIX', 'FIX', 'PATCH', 'HOTFIX', 'BETA', 'ALPHA', 'DEMO', 'PREORDER',
68
    ];
69
70
    protected ?string $apiKey;
71
72
    public function __construct(?string $apiKey = null)
73
    {
74
        $this->apiKey = $apiKey ?? config('services.steam.api_key');
75
    }
76
77
    /**
78
     * Search for a game by title and return the best match's Steam App ID.
79
     */
80
    public function search(string $title): ?int
81
    {
82
        $cleanTitle = $this->cleanTitle($title);
83
        if (empty($cleanTitle)) {
84
            Log::debug('SteamService: Empty title after cleaning', ['original' => $title]);
85
            return null;
86
        }
87
88
        // Check failed lookup cache
89
        $cacheKey = 'steam_search_failed:' . md5(mb_strtolower($cleanTitle));
90
        if (Cache::has($cacheKey)) {
91
            Log::debug('SteamService: Skipping previously failed search', ['title' => $cleanTitle]);
92
            return null;
93
        }
94
95
        // Check successful search cache
96
        $successCacheKey = 'steam_search:' . md5(mb_strtolower($cleanTitle));
97
        $cached = Cache::get($successCacheKey);
98
        if ($cached !== null) {
99
            Log::debug('SteamService: Using cached search result', ['title' => $cleanTitle, 'appid' => $cached]);
100
            return (int) $cached;
101
        }
102
103
        $appId = $this->performSearch($cleanTitle);
104
105
        if ($appId !== null) {
106
            Cache::put($successCacheKey, $appId, self::SEARCH_CACHE_TTL);
107
            Log::info('SteamService: Found match', ['title' => $cleanTitle, 'appid' => $appId]);
108
        } else {
109
            Cache::put($cacheKey, true, self::FAILED_LOOKUP_CACHE_TTL);
110
            Log::debug('SteamService: No match found', ['title' => $cleanTitle]);
111
        }
112
113
        return $appId;
114
    }
115
116
    /**
117
     * Get complete game details from Steam.
118
     *
119
     * @return array{
120
     *     title: string,
121
     *     steamid: int,
122
     *     description: ?string,
123
     *     detailed_description: ?string,
124
     *     about: ?string,
125
     *     short_description: ?string,
126
     *     cover: ?string,
127
     *     backdrop: ?string,
128
     *     screenshots: array,
129
     *     movies: array,
130
     *     trailer: ?string,
131
     *     publisher: ?string,
132
     *     developers: array,
133
     *     releasedate: ?string,
134
     *     genres: string,
135
     *     categories: array,
136
     *     rating: ?int,
137
     *     metacritic_score: ?int,
138
     *     metacritic_url: ?string,
139
     *     price: ?array,
140
     *     platforms: array,
141
     *     requirements: array,
142
     *     dlc: array,
143
     *     achievements: ?int,
144
     *     recommendations: ?int,
145
     *     website: ?string,
146
     *     support_url: ?string,
147
     *     legal_notice: ?string,
148
     *     directurl: string,
149
     *     type: string,
150
     * }|false
151
     */
152
    public function getGameDetails(int $appId): array|false
153
    {
154
        // Check cache first
155
        $cacheKey = "steam_app_details:{$appId}";
156
        $cached = Cache::get($cacheKey);
157
        if ($cached !== null) {
158
            return $cached;
159
        }
160
161
        $data = $this->fetchAppDetails($appId);
162
        if ($data === null) {
163
            return false;
164
        }
165
166
        $result = $this->transformAppDetails($data, $appId);
167
168
        Cache::put($cacheKey, $result, self::APP_DETAILS_CACHE_TTL);
169
170
        return $result;
171
    }
172
173
    /**
174
     * Alias for getGameDetails for backward compatibility.
175
     */
176
    public function getAll(int $appId): array|false
177
    {
178
        return $this->getGameDetails($appId);
179
    }
180
181
    /**
182
     * Get Steam player count for a game.
183
     */
184
    public function getPlayerCount(int $appId): ?int
185
    {
186
        if (empty($this->apiKey)) {
187
            return null;
188
        }
189
190
        $cacheKey = "steam_player_count:{$appId}";
191
        $cached = Cache::get($cacheKey);
192
        if ($cached !== null) {
193
            return (int) $cached;
194
        }
195
196
        try {
197
            $response = $this->makeRequest(
198
                self::STEAM_API_BASE . '/ISteamUserStats/GetNumberOfCurrentPlayers/v1/',
199
                ['appid' => $appId]
200
            );
201
202
            if ($response && isset($response['response']['player_count'])) {
203
                $count = (int) $response['response']['player_count'];
204
                Cache::put($cacheKey, $count, 300); // 5 minute cache
205
                return $count;
206
            }
207
        } catch (\Exception $e) {
208
            Log::warning('SteamService: Failed to get player count', [
209
                'appid' => $appId,
210
                'error' => $e->getMessage()
211
            ]);
212
        }
213
214
        return null;
215
    }
216
217
    /**
218
     * Get game reviews summary.
219
     */
220
    public function getReviewsSummary(int $appId): ?array
221
    {
222
        $cacheKey = "steam_reviews:{$appId}";
223
        $cached = Cache::get($cacheKey);
224
        if ($cached !== null) {
225
            return $cached;
226
        }
227
228
        try {
229
            $response = Http::timeout(10)
230
                ->get(self::STEAM_STORE_BASE . '/appreviews/' . $appId, [
231
                    'json' => 1,
232
                    'language' => 'all',
233
                    'purchase_type' => 'all',
234
                ])
235
                ->json();
236
237
            if ($response && isset($response['query_summary'])) {
238
                $summary = [
239
                    'total_positive' => $response['query_summary']['total_positive'] ?? 0,
240
                    'total_negative' => $response['query_summary']['total_negative'] ?? 0,
241
                    'total_reviews' => $response['query_summary']['total_reviews'] ?? 0,
242
                    'review_score' => $response['query_summary']['review_score'] ?? 0,
243
                    'review_score_desc' => $response['query_summary']['review_score_desc'] ?? 'No Reviews',
244
                ];
245
246
                Cache::put($cacheKey, $summary, 3600); // 1 hour cache
247
                return $summary;
248
            }
249
        } catch (\Exception $e) {
250
            Log::warning('SteamService: Failed to get reviews', [
251
                'appid' => $appId,
252
                'error' => $e->getMessage()
253
            ]);
254
        }
255
256
        return null;
257
    }
258
259
    /**
260
     * Get DLC list for a game.
261
     */
262
    public function getDLCList(int $appId): array
263
    {
264
        $details = $this->getGameDetails($appId);
265
        if ($details === false) {
266
            return [];
267
        }
268
269
        $dlcIds = $details['dlc'] ?? [];
270
        if (empty($dlcIds)) {
271
            return [];
272
        }
273
274
        $dlcList = [];
275
        foreach (array_slice($dlcIds, 0, 20) as $dlcId) { // Limit to prevent too many API calls
276
            $dlcDetails = $this->getGameDetails($dlcId);
277
            if ($dlcDetails !== false) {
278
                $dlcList[] = [
279
                    'appid' => $dlcId,
280
                    'name' => $dlcDetails['title'],
281
                    'price' => $dlcDetails['price'] ?? null,
282
                ];
283
            }
284
        }
285
286
        return $dlcList;
287
    }
288
289
    /**
290
     * Populate the steam_apps table with the full app list from Steam.
291
     */
292
    public function populateSteamAppsTable(?callable $progressCallback = null): array
293
    {
294
        $stats = ['inserted' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0];
295
296
        try {
297
            $appList = $this->getFullAppList();
298
            if (empty($appList)) {
299
                Log::error('SteamService: Failed to retrieve app list from Steam');
300
                return $stats;
301
            }
302
303
            $total = count($appList);
304
            $processed = 0;
305
306
            // Process in chunks to avoid memory issues
307
            $chunks = array_chunk($appList, 1000);
308
309
            foreach ($chunks as $chunk) {
310
                $existingApps = SteamApp::query()
311
                    ->whereIn('appid', Arr::pluck($chunk, 'appid'))
312
                    ->pluck('appid')
313
                    ->toArray();
314
315
                $toInsert = [];
316
                foreach ($chunk as $app) {
317
                    $processed++;
318
319
                    if (empty($app['name']) || !isset($app['appid'])) {
320
                        $stats['skipped']++;
321
                        continue;
322
                    }
323
324
                    if (in_array($app['appid'], $existingApps, true)) {
325
                        $stats['skipped']++;
326
                        continue;
327
                    }
328
329
                    $toInsert[] = [
330
                        'appid' => $app['appid'],
331
                        'name' => $app['name'],
332
                    ];
333
                }
334
335
                if (!empty($toInsert)) {
336
                    try {
337
                        SteamApp::query()->insert($toInsert);
338
                        $stats['inserted'] += count($toInsert);
339
                    } catch (\Exception $e) {
340
                        Log::warning('SteamService: Batch insert failed, trying individual inserts', [
341
                            'error' => $e->getMessage()
342
                        ]);
343
344
                        foreach ($toInsert as $app) {
345
                            try {
346
                                SteamApp::query()->insertOrIgnore($app);
347
                                $stats['inserted']++;
348
                            } catch (\Exception $e2) {
349
                                $stats['errors']++;
350
                            }
351
                        }
352
                    }
353
                }
354
355
                if ($progressCallback !== null) {
356
                    $progressCallback($processed, $total);
357
                }
358
            }
359
360
            Log::info('SteamService: App list populated', $stats);
361
        } catch (\Exception $e) {
362
            Log::error('SteamService: Failed to populate app list', ['error' => $e->getMessage()]);
363
            $stats['errors']++;
364
        }
365
366
        return $stats;
367
    }
368
369
    /**
370
     * Get the full list of Steam apps.
371
     */
372
    public function getFullAppList(): array
373
    {
374
        $cacheKey = 'steam_full_app_list';
375
        $cached = Cache::get($cacheKey);
376
        if ($cached !== null) {
377
            return $cached;
378
        }
379
380
        try {
381
            $response = Http::timeout(60)
382
                ->get(self::STEAM_API_BASE . '/ISteamApps/GetAppList/v2/')
383
                ->json();
384
385
            if ($response && isset($response['applist']['apps'])) {
386
                $apps = $response['applist']['apps'];
387
                Cache::put($cacheKey, $apps, self::APP_LIST_CACHE_TTL);
388
                return $apps;
389
            }
390
        } catch (\Exception $e) {
391
            Log::error('SteamService: Failed to fetch app list', ['error' => $e->getMessage()]);
392
        }
393
394
        return [];
395
    }
396
397
    /**
398
     * Search for games and return multiple matches.
399
     *
400
     * @return Collection<int, array{appid: int, name: string, score: float}>
401
     */
402
    public function searchMultiple(string $title, int $limit = 10): Collection
403
    {
404
        $cleanTitle = $this->cleanTitle($title);
405
        if (empty($cleanTitle)) {
406
            return collect();
407
        }
408
409
        $matches = $this->findMatches($cleanTitle, $limit * 2); // Get more to filter
410
411
        return collect($matches)
412
            ->filter(fn($m) => $m['score'] >= self::RELAXED_MATCH_THRESHOLD)
413
            ->sortByDesc('score')
414
            ->take($limit)
415
            ->values();
416
    }
417
418
    // ========================================
419
    // Protected Methods
420
    // ========================================
421
422
    /**
423
     * Perform the actual search with multiple strategies.
424
     */
425
    protected function performSearch(string $title): ?int
426
    {
427
        $matches = $this->findMatches($title);
428
429
        if (empty($matches)) {
430
            return null;
431
        }
432
433
        // Get the best match
434
        $bestMatch = $matches[0];
435
436
        if ($bestMatch['score'] >= self::MATCH_THRESHOLD) {
437
            return $bestMatch['appid'];
438
        }
439
440
        // Try relaxed threshold for close matches
441
        if ($bestMatch['score'] >= self::RELAXED_MATCH_THRESHOLD) {
442
            // Verify with additional checks
443
            $details = $this->fetchAppDetails($bestMatch['appid']);
444
            if ($details !== null && $this->isGameType($details)) {
445
                return $bestMatch['appid'];
446
            }
447
        }
448
449
        return null;
450
    }
451
452
    /**
453
     * Find matching games from the database.
454
     *
455
     * @return array<int, array{appid: int, name: string, score: float}>
456
     */
457
    protected function findMatches(string $title, int $limit = 25): array
458
    {
459
        $variants = $this->generateQueryVariants($title);
460
        $matches = [];
461
        $seenAppIds = [];
462
463
        foreach ($variants as $variant) {
464
            // Try Scout full-text search first
465
            try {
466
                $results = SteamApp::search($variant)->take($limit)->get();
467
                foreach ($results as $result) {
468
                    $appid = $result->appid ?? null;
469
                    $name = $result->name ?? null;
470
471
                    if ($appid === null || $name === null || isset($seenAppIds[$appid])) {
472
                        continue;
473
                    }
474
475
                    $score = $this->scoreTitle($name, $title);
476
                    if ($score >= self::RELAXED_MATCH_THRESHOLD) {
477
                        $seenAppIds[$appid] = true;
478
                        $matches[] = [
479
                            'appid' => (int) $appid,
480
                            'name' => $name,
481
                            'score' => $score,
482
                        ];
483
                    }
484
                }
485
            } catch (\Exception $e) {
486
                Log::debug('SteamService: Scout search failed', ['error' => $e->getMessage()]);
487
            }
488
489
            // LIKE fallback
490
            $likeTerm = $this->buildLikePattern($variant);
491
            try {
492
                $fallbacks = SteamApp::query()
493
                    ->select(['appid', 'name'])
494
                    ->where('name', 'like', $likeTerm)
495
                    ->limit($limit)
496
                    ->get();
497
498
                foreach ($fallbacks as $row) {
499
                    $appid = $row->appid;
500
                    $name = $row->name;
501
502
                    if (isset($seenAppIds[$appid])) {
503
                        continue;
504
                    }
505
506
                    $score = $this->scoreTitle($name, $title);
507
                    if ($score >= self::RELAXED_MATCH_THRESHOLD) {
508
                        $seenAppIds[$appid] = true;
509
                        $matches[] = [
510
                            'appid' => (int) $appid,
511
                            'name' => $name,
512
                            'score' => $score,
513
                        ];
514
                    }
515
                }
516
            } catch (\Exception $e) {
517
                Log::debug('SteamService: LIKE search failed', ['error' => $e->getMessage()]);
518
            }
519
        }
520
521
        // Sort by score descending
522
        usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
523
524
        return array_slice($matches, 0, $limit);
525
    }
526
527
    /**
528
     * Fetch app details from Steam API.
529
     */
530
    protected function fetchAppDetails(int $appId): ?array
531
    {
532
        $result = RateLimiter::attempt(
533
            self::RATE_LIMIT_KEY,
534
            self::REQUESTS_PER_MINUTE,
535
            function () use ($appId) {
536
                try {
537
                    $response = Http::timeout(15)
538
                        ->get(self::STEAM_STORE_BASE . '/appdetails', [
539
                            'appids' => $appId,
540
                            'cc' => 'us',
541
                            'l' => 'english',
542
                        ])
543
                        ->json();
544
545
                    if ($response && isset($response[(string) $appId]['success']) && $response[(string) $appId]['success'] === true) {
546
                        return $response[(string) $appId]['data'];
547
                    }
548
                } catch (\Exception $e) {
549
                    Log::warning('SteamService: Failed to fetch app details', [
550
                        'appid' => $appId,
551
                        'error' => $e->getMessage()
552
                    ]);
553
                }
554
555
                return null;
556
            },
557
            self::DECAY_SECONDS
558
        );
559
560
        // RateLimiter::attempt returns true when rate limit is exceeded
561
        return is_array($result) ? $result : null;
562
    }
563
564
    /**
565
     * Transform Steam API response to our standard format.
566
     */
567
    protected function transformAppDetails(array $data, int $appId): array
568
    {
569
        // Extract screenshots
570
        $screenshots = [];
571
        if (!empty($data['screenshots'])) {
572
            foreach ($data['screenshots'] as $ss) {
573
                $screenshots[] = [
574
                    'thumbnail' => $ss['path_thumbnail'] ?? null,
575
                    'full' => $ss['path_full'] ?? null,
576
                ];
577
            }
578
        }
579
580
        // Extract movies/trailers
581
        $movies = [];
582
        $trailerUrl = null;
583
        if (!empty($data['movies'])) {
584
            foreach ($data['movies'] as $movie) {
585
                $movieData = [
586
                    'id' => $movie['id'] ?? null,
587
                    'name' => $movie['name'] ?? null,
588
                    'thumbnail' => $movie['thumbnail'] ?? null,
589
                    'webm' => $movie['webm']['max'] ?? ($movie['webm']['480'] ?? null),
590
                    'mp4' => $movie['mp4']['max'] ?? ($movie['mp4']['480'] ?? null),
591
                ];
592
                $movies[] = $movieData;
593
594
                if ($trailerUrl === null && !empty($movieData['mp4'])) {
595
                    $trailerUrl = $movieData['mp4'];
596
                }
597
            }
598
        }
599
600
        // Extract genres
601
        $genres = [];
602
        if (!empty($data['genres'])) {
603
            foreach ($data['genres'] as $genre) {
604
                $genres[] = $genre['description'] ?? '';
605
            }
606
        }
607
608
        // Extract categories (multiplayer, co-op, etc.)
609
        $categories = [];
610
        if (!empty($data['categories'])) {
611
            foreach ($data['categories'] as $cat) {
612
                $categories[] = $cat['description'] ?? '';
613
            }
614
        }
615
616
        // Extract price info
617
        $price = null;
618
        if (isset($data['price_overview'])) {
619
            $price = [
620
                'currency' => $data['price_overview']['currency'] ?? 'USD',
621
                'initial' => ($data['price_overview']['initial'] ?? 0) / 100,
622
                'final' => ($data['price_overview']['final'] ?? 0) / 100,
623
                'discount_percent' => $data['price_overview']['discount_percent'] ?? 0,
624
                'final_formatted' => $data['price_overview']['final_formatted'] ?? null,
625
            ];
626
        } elseif ($data['is_free'] ?? false) {
627
            $price = [
628
                'currency' => 'USD',
629
                'initial' => 0,
630
                'final' => 0,
631
                'discount_percent' => 0,
632
                'final_formatted' => 'Free',
633
            ];
634
        }
635
636
        // Extract platforms
637
        $platforms = [];
638
        if (!empty($data['platforms'])) {
639
            if ($data['platforms']['windows'] ?? false) {
640
                $platforms[] = 'Windows';
641
            }
642
            if ($data['platforms']['mac'] ?? false) {
643
                $platforms[] = 'Mac';
644
            }
645
            if ($data['platforms']['linux'] ?? false) {
646
                $platforms[] = 'Linux';
647
            }
648
        }
649
650
        // Extract requirements
651
        $requirements = [];
652
        if (!empty($data['pc_requirements'])) {
653
            $requirements['pc'] = [
654
                'minimum' => $data['pc_requirements']['minimum'] ?? null,
655
                'recommended' => $data['pc_requirements']['recommended'] ?? null,
656
            ];
657
        }
658
        if (!empty($data['mac_requirements'])) {
659
            $requirements['mac'] = [
660
                'minimum' => $data['mac_requirements']['minimum'] ?? null,
661
                'recommended' => $data['mac_requirements']['recommended'] ?? null,
662
            ];
663
        }
664
        if (!empty($data['linux_requirements'])) {
665
            $requirements['linux'] = [
666
                'minimum' => $data['linux_requirements']['minimum'] ?? null,
667
                'recommended' => $data['linux_requirements']['recommended'] ?? null,
668
            ];
669
        }
670
671
        // Extract release date
672
        $releaseDate = null;
673
        if (!empty($data['release_date']['date'])) {
674
            try {
675
                $releaseDate = Carbon::parse($data['release_date']['date'])->format('Y-m-d');
676
            } catch (\Exception $e) {
677
                $releaseDate = $data['release_date']['date'];
678
            }
679
        }
680
681
        // Build publisher string
682
        $publisher = null;
683
        if (!empty($data['publishers'])) {
684
            $publisher = implode(', ', array_filter(array_map('strval', $data['publishers'])));
685
        }
686
687
        // Build developers array
688
        $developers = [];
689
        if (!empty($data['developers'])) {
690
            $developers = array_filter(array_map('strval', $data['developers']));
691
        }
692
693
        return [
694
            'title' => $data['name'] ?? '',
695
            'steamid' => $appId,
696
            'type' => $data['type'] ?? 'game',
697
            'description' => $data['short_description'] ?? null,
698
            'detailed_description' => $data['detailed_description'] ?? null,
699
            'about' => $data['about_the_game'] ?? null,
700
            'short_description' => $data['short_description'] ?? null,
701
            'cover' => $data['header_image'] ?? null,
702
            'backdrop' => $data['background'] ?? ($data['background_raw'] ?? null),
703
            'screenshots' => $screenshots,
704
            'movies' => $movies,
705
            'trailer' => $trailerUrl,
706
            'publisher' => $publisher,
707
            'developers' => $developers,
708
            'releasedate' => $releaseDate,
709
            'genres' => implode(',', array_filter($genres)),
710
            'categories' => $categories,
711
            'rating' => $data['metacritic']['score'] ?? null,
712
            'metacritic_score' => $data['metacritic']['score'] ?? null,
713
            'metacritic_url' => $data['metacritic']['url'] ?? null,
714
            'price' => $price,
715
            'platforms' => $platforms,
716
            'requirements' => $requirements,
717
            'dlc' => $data['dlc'] ?? [],
718
            'achievements' => $data['achievements']['total'] ?? null,
719
            'recommendations' => $data['recommendations']['total'] ?? null,
720
            'website' => $data['website'] ?? null,
721
            'support_url' => $data['support_info']['url'] ?? null,
722
            'legal_notice' => $data['legal_notice'] ?? null,
723
            'directurl' => self::STEAM_STORE_URL . $appId,
724
        ];
725
    }
726
727
    /**
728
     * Check if the app is a game (not DLC, video, etc.).
729
     */
730
    protected function isGameType(array $data): bool
731
    {
732
        $type = $data['type'] ?? '';
733
        return in_array($type, ['game', 'demo'], true);
734
    }
735
736
    /**
737
     * Make a rate-limited request to Steam API.
738
     */
739
    protected function makeRequest(string $url, array $params = []): ?array
740
    {
741
        return RateLimiter::attempt(
742
            self::RATE_LIMIT_KEY,
743
            self::REQUESTS_PER_MINUTE,
744
            function () use ($url, $params) {
745
                if ($this->apiKey !== null) {
746
                    $params['key'] = $this->apiKey;
747
                }
748
749
                try {
750
                    return Http::timeout(15)->get($url, $params)->json();
751
                } catch (\Exception $e) {
752
                    Log::warning('SteamService: API request failed', [
753
                        'url' => $url,
754
                        'error' => $e->getMessage()
755
                    ]);
756
                    return null;
757
                }
758
            },
759
            self::DECAY_SECONDS
760
        );
761
    }
762
763
    // ========================================
764
    // Title Matching & Normalization
765
    // ========================================
766
767
    /**
768
     * Clean a release title for searching.
769
     */
770
    public function cleanTitle(string $title): string
771
    {
772
        $title = trim($title);
773
        if ($title === '') {
774
            return '';
775
        }
776
777
        // URL decode
778
        $title = urldecode($title);
779
780
        // Remove file extensions
781
        $title = (string) preg_replace('/\.(zip|rar|7z|iso|nfo|sfv|exe|mkv|mp4|avi)$/i', '', $title);
782
783
        // Remove bracketed content
784
        $title = (string) preg_replace('/\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/u', ' ', $title);
785
786
        // Remove scene groups at end
787
        $groupPattern = '/\s*[-_]\s*(' . implode('|', array_map('preg_quote', self::SCENE_GROUPS)) . ')\s*$/i';
788
        $title = (string) preg_replace($groupPattern, '', $title);
789
790
        // Remove edition tags (multi-word patterns first)
791
        $editionPatterns = [
792
            '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i',
793
            '/\bGOTY[\s._-]*(Edition)?\b/i',
794
            '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i',
795
            '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i',
796
            '/\bEdition\b/i', // Remove standalone "Edition"
797
        ];
798
        foreach ($editionPatterns as $pattern) {
799
            $title = (string) preg_replace($pattern, ' ', $title);
800
        }
801
802
        // Remove standalone edition tags
803
        foreach (self::EDITION_TAGS as $tag) {
804
            // Skip tags already handled above
805
            if (stripos($tag, 'EDITION') !== false) {
806
                continue;
807
            }
808
            $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\b/i', ' ', $title);
809
        }
810
811
        // Remove release tags
812
        foreach (self::RELEASE_TAGS as $tag) {
813
            $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\d*\b/i', ' ', $title);
814
        }
815
816
        // Remove DLCs tag (common in scene releases)
817
        $title = (string) preg_replace('/\b(Incl(?:uding)?\.?\s*)?DLCs?\b/i', ' ', $title);
818
819
        // Remove version numbers (v1.2.3, 1.2.3.4, etc.)
820
        $title = (string) preg_replace('/\bv?\d+(?:\.\d+){2,}\b/i', ' ', $title);
821
822
        // Replace separators with spaces
823
        $title = (string) preg_replace('/[._+\-]+/', ' ', $title);
824
825
        // Clean up
826
        $title = (string) preg_replace('/\s+/', ' ', $title);
827
        $title = trim($title, " \t\n\r\0\x0B-_");
828
829
        return $title;
830
    }
831
832
    /**
833
     * Generate query variants for better matching.
834
     *
835
     * @return array<string>
836
     */
837
    protected function generateQueryVariants(string $title): array
838
    {
839
        $variants = [$title];
840
841
        // Without edition tags (already cleaned, but be sure)
842
        $stripped = $this->stripEditionTags($title);
843
        if ($stripped !== $title && $stripped !== '') {
844
            $variants[] = $stripped;
845
        }
846
847
        // Without parentheses content
848
        $noParen = (string) preg_replace('/\s*\([^)]*\)\s*/', ' ', $stripped);
849
        $noParen = trim(preg_replace('/\s+/', ' ', $noParen) ?? '');
850
        if ($noParen !== $stripped && $noParen !== '') {
851
            $variants[] = $noParen;
852
        }
853
854
        // Left side of colon (base title)
855
        if (str_contains($noParen, ':')) {
856
            $left = trim(explode(':', $noParen, 2)[0]);
857
            if ($left !== '' && $left !== $noParen) {
858
                $variants[] = $left;
859
            }
860
        }
861
862
        // Normalized version
863
        $normalized = $this->normalizeTitle($noParen);
864
        if ($normalized !== $noParen && $normalized !== '') {
865
            $variants[] = $normalized;
866
        }
867
868
        // De-duplicate while preserving order
869
        $unique = [];
870
        $seen = [];
871
        foreach ($variants as $v) {
872
            $v = trim($v);
873
            if ($v === '') {
874
                continue;
875
            }
876
            $lower = mb_strtolower($v);
877
            if (!isset($seen[$lower])) {
878
                $seen[$lower] = true;
879
                $unique[] = $v;
880
            }
881
        }
882
883
        return $unique;
884
    }
885
886
    /**
887
     * Normalize a title for comparison.
888
     */
889
    protected function normalizeTitle(string $title): string
890
    {
891
        $s = mb_strtolower($title);
892
893
        // Replace separators
894
        $s = (string) preg_replace('/[._\-+]+/u', ' ', $s);
895
896
        // Convert roman numerals
897
        $s = $this->replaceRomanNumerals($s);
898
899
        // Remove common noise
900
        $noise = array_merge(
901
            array_map('strtolower', self::SCENE_GROUPS),
902
            array_map('strtolower', self::EDITION_TAGS),
903
            array_map('strtolower', self::RELEASE_TAGS),
904
            ['pc', 'win', 'windows', 'x86', 'x64', 'x32']
905
        );
906
        $noisePattern = '/\b(' . implode('|', array_map(fn($w) => preg_quote($w, '/'), $noise)) . ')\b/u';
907
        $s = (string) preg_replace($noisePattern, ' ', $s);
908
909
        // Remove non-alphanumeric
910
        $s = (string) preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $s);
911
        $s = (string) preg_replace('/\s+/u', ' ', $s);
912
        $s = trim($s);
913
914
        // Remove leading articles
915
        $s = (string) preg_replace('/^(the|a|an)\s+/u', '', $s);
916
917
        return $s;
918
    }
919
920
    /**
921
     * Strip edition tags from a title.
922
     */
923
    protected function stripEditionTags(string $title): string
924
    {
925
        // Remove compound edition phrases first
926
        $compoundPatterns = [
927
            '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i',
928
            '/\bGOTY[\s._-]*(Edition)?\b/i',
929
            '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i',
930
            '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i',
931
            '/\bEdition\b/i', // Remove standalone "Edition"
932
        ];
933
        foreach ($compoundPatterns as $pattern) {
934
            $title = (string) preg_replace($pattern, ' ', $title);
935
        }
936
937
        // Remove remaining individual edition tags
938
        foreach (self::EDITION_TAGS as $tag) {
939
            $title = (string) preg_replace('/\s*[-_]?\s*' . preg_quote($tag, '/') . '\s*/i', ' ', $title);
940
        }
941
        return trim(preg_replace('/\s+/', ' ', $title) ?? '');
942
    }
943
944
    /**
945
     * Score a candidate title against the original search term.
946
     */
947
    protected function scoreTitle(string $candidate, string $original): float
948
    {
949
        $normCand = $this->normalizeTitle($candidate);
950
        $normOrig = $this->normalizeTitle($original);
951
952
        if ($normCand === '' || $normOrig === '') {
953
            return 0.0;
954
        }
955
956
        // Perfect match
957
        if ($normCand === $normOrig) {
958
            return 100.0;
959
        }
960
961
        // Token-based scoring
962
        $tokensCand = $this->tokenize($normCand);
963
        $tokensOrig = $this->tokenize($normOrig);
964
965
        // Token containment check
966
        $intersect = count(array_intersect($tokensCand, $tokensOrig));
967
        $union = count(array_unique(array_merge($tokensCand, $tokensOrig)));
968
        $jaccard = $union > 0 ? ($intersect / $union) : 0.0;
969
970
        // Levenshtein similarity
971
        $lev = levenshtein($normCand, $normOrig);
972
        $maxLen = max(strlen($normCand), strlen($normOrig));
973
        $levSim = $maxLen > 0 ? (1.0 - ($lev / $maxLen)) : 0.0;
974
975
        // Prefix bonus
976
        $prefixBoost = 0.0;
977
        if (str_starts_with($normCand, $normOrig) || str_starts_with($normOrig, $normCand)) {
978
            $prefixBoost = 0.15;
979
        }
980
981
        // If all tokens of one are in the other
982
        $candInOrig = empty(array_diff($tokensCand, $tokensOrig));
983
        $origInCand = empty(array_diff($tokensOrig, $tokensCand));
984
        if ($candInOrig || $origInCand) {
985
            $shortCount = min(count($tokensCand), count($tokensOrig));
986
            $longCount = max(count($tokensCand), count($tokensOrig));
987
            $coverage = $longCount > 0 ? ($shortCount / $longCount) : 0.0;
988
            if ($coverage >= 0.8) {
989
                return 100.0;
990
            }
991
        }
992
993
        // Combined score
994
        $score = ($jaccard * 0.6 + $levSim * 0.4 + $prefixBoost) * 100.0;
995
996
        return max(0.0, min(100.0, $score));
997
    }
998
999
    /**
1000
     * Tokenize a string.
1001
     */
1002
    protected function tokenize(string $s): array
1003
    {
1004
        $parts = preg_split('/\s+/u', mb_strtolower($s)) ?: [];
1005
        $seen = [];
1006
        $out = [];
1007
        foreach ($parts as $p) {
1008
            $p = trim($p);
1009
            if ($p === '' || isset($seen[$p])) {
1010
                continue;
1011
            }
1012
            $seen[$p] = true;
1013
            $out[] = $p;
1014
        }
1015
        return $out;
1016
    }
1017
1018
    /**
1019
     * Replace roman numerals with arabic numbers.
1020
     */
1021
    protected function replaceRomanNumerals(string $s): string
1022
    {
1023
        $map = [
1024
            'xx' => '20', 'xix' => '19', 'xviii' => '18', 'xvii' => '17', 'xvi' => '16',
1025
            'xv' => '15', 'xiv' => '14', 'xiii' => '13', 'xii' => '12', 'xi' => '11',
1026
            'x' => '10', 'ix' => '9', 'viii' => '8', 'vii' => '7', 'vi' => '6',
1027
            'v' => '5', 'iv' => '4', 'iii' => '3', 'ii' => '2',
1028
        ];
1029
1030
        // Don't replace standalone 'i' as it's too common in titles
1031
        foreach ($map as $roman => $arabic) {
1032
            $s = (string) preg_replace('/\b' . $roman . '\b/ui', $arabic, $s);
1033
        }
1034
1035
        return $s;
1036
    }
1037
1038
    /**
1039
     * Build a SQL LIKE pattern from a search term.
1040
     */
1041
    protected function buildLikePattern(string $term): string
1042
    {
1043
        $normalized = $this->normalizeTitle($term);
1044
        $pattern = preg_replace('/\s+/', '%', $normalized);
1045
        $pattern = trim($pattern ?? '');
1046
1047
        return $pattern === '' ? '%' : '%' . $pattern . '%';
1048
    }
1049
1050
    /**
1051
     * Clear all cached data.
1052
     */
1053
    public function clearCache(): void
1054
    {
1055
        // Note: This is a placeholder. In production, you'd use tagged caches
1056
        // or implement specific cache key management.
1057
        Log::info('SteamService: Cache clear requested');
1058
    }
1059
}
1060
1061