Issues (578)

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
        return 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
561
    /**
562
     * Transform Steam API response to our standard format.
563
     */
564
    protected function transformAppDetails(array $data, int $appId): array
565
    {
566
        // Extract screenshots
567
        $screenshots = [];
568
        if (!empty($data['screenshots'])) {
569
            foreach ($data['screenshots'] as $ss) {
570
                $screenshots[] = [
571
                    'thumbnail' => $ss['path_thumbnail'] ?? null,
572
                    'full' => $ss['path_full'] ?? null,
573
                ];
574
            }
575
        }
576
577
        // Extract movies/trailers
578
        $movies = [];
579
        $trailerUrl = null;
580
        if (!empty($data['movies'])) {
581
            foreach ($data['movies'] as $movie) {
582
                $movieData = [
583
                    'id' => $movie['id'] ?? null,
584
                    'name' => $movie['name'] ?? null,
585
                    'thumbnail' => $movie['thumbnail'] ?? null,
586
                    'webm' => $movie['webm']['max'] ?? ($movie['webm']['480'] ?? null),
587
                    'mp4' => $movie['mp4']['max'] ?? ($movie['mp4']['480'] ?? null),
588
                ];
589
                $movies[] = $movieData;
590
591
                if ($trailerUrl === null && !empty($movieData['mp4'])) {
592
                    $trailerUrl = $movieData['mp4'];
593
                }
594
            }
595
        }
596
597
        // Extract genres
598
        $genres = [];
599
        if (!empty($data['genres'])) {
600
            foreach ($data['genres'] as $genre) {
601
                $genres[] = $genre['description'] ?? '';
602
            }
603
        }
604
605
        // Extract categories (multiplayer, co-op, etc.)
606
        $categories = [];
607
        if (!empty($data['categories'])) {
608
            foreach ($data['categories'] as $cat) {
609
                $categories[] = $cat['description'] ?? '';
610
            }
611
        }
612
613
        // Extract price info
614
        $price = null;
615
        if (isset($data['price_overview'])) {
616
            $price = [
617
                'currency' => $data['price_overview']['currency'] ?? 'USD',
618
                'initial' => ($data['price_overview']['initial'] ?? 0) / 100,
619
                'final' => ($data['price_overview']['final'] ?? 0) / 100,
620
                'discount_percent' => $data['price_overview']['discount_percent'] ?? 0,
621
                'final_formatted' => $data['price_overview']['final_formatted'] ?? null,
622
            ];
623
        } elseif ($data['is_free'] ?? false) {
624
            $price = [
625
                'currency' => 'USD',
626
                'initial' => 0,
627
                'final' => 0,
628
                'discount_percent' => 0,
629
                'final_formatted' => 'Free',
630
            ];
631
        }
632
633
        // Extract platforms
634
        $platforms = [];
635
        if (!empty($data['platforms'])) {
636
            if ($data['platforms']['windows'] ?? false) {
637
                $platforms[] = 'Windows';
638
            }
639
            if ($data['platforms']['mac'] ?? false) {
640
                $platforms[] = 'Mac';
641
            }
642
            if ($data['platforms']['linux'] ?? false) {
643
                $platforms[] = 'Linux';
644
            }
645
        }
646
647
        // Extract requirements
648
        $requirements = [];
649
        if (!empty($data['pc_requirements'])) {
650
            $requirements['pc'] = [
651
                'minimum' => $data['pc_requirements']['minimum'] ?? null,
652
                'recommended' => $data['pc_requirements']['recommended'] ?? null,
653
            ];
654
        }
655
        if (!empty($data['mac_requirements'])) {
656
            $requirements['mac'] = [
657
                'minimum' => $data['mac_requirements']['minimum'] ?? null,
658
                'recommended' => $data['mac_requirements']['recommended'] ?? null,
659
            ];
660
        }
661
        if (!empty($data['linux_requirements'])) {
662
            $requirements['linux'] = [
663
                'minimum' => $data['linux_requirements']['minimum'] ?? null,
664
                'recommended' => $data['linux_requirements']['recommended'] ?? null,
665
            ];
666
        }
667
668
        // Extract release date
669
        $releaseDate = null;
670
        if (!empty($data['release_date']['date'])) {
671
            try {
672
                $releaseDate = Carbon::parse($data['release_date']['date'])->format('Y-m-d');
673
            } catch (\Exception $e) {
674
                $releaseDate = $data['release_date']['date'];
675
            }
676
        }
677
678
        // Build publisher string
679
        $publisher = null;
680
        if (!empty($data['publishers'])) {
681
            $publisher = implode(', ', array_filter(array_map('strval', $data['publishers'])));
682
        }
683
684
        // Build developers array
685
        $developers = [];
686
        if (!empty($data['developers'])) {
687
            $developers = array_filter(array_map('strval', $data['developers']));
688
        }
689
690
        return [
691
            'title' => $data['name'] ?? '',
692
            'steamid' => $appId,
693
            'type' => $data['type'] ?? 'game',
694
            'description' => $data['short_description'] ?? null,
695
            'detailed_description' => $data['detailed_description'] ?? null,
696
            'about' => $data['about_the_game'] ?? null,
697
            'short_description' => $data['short_description'] ?? null,
698
            'cover' => $data['header_image'] ?? null,
699
            'backdrop' => $data['background'] ?? ($data['background_raw'] ?? null),
700
            'screenshots' => $screenshots,
701
            'movies' => $movies,
702
            'trailer' => $trailerUrl,
703
            'publisher' => $publisher,
704
            'developers' => $developers,
705
            'releasedate' => $releaseDate,
706
            'genres' => implode(',', array_filter($genres)),
707
            'categories' => $categories,
708
            'rating' => $data['metacritic']['score'] ?? null,
709
            'metacritic_score' => $data['metacritic']['score'] ?? null,
710
            'metacritic_url' => $data['metacritic']['url'] ?? null,
711
            'price' => $price,
712
            'platforms' => $platforms,
713
            'requirements' => $requirements,
714
            'dlc' => $data['dlc'] ?? [],
715
            'achievements' => $data['achievements']['total'] ?? null,
716
            'recommendations' => $data['recommendations']['total'] ?? null,
717
            'website' => $data['website'] ?? null,
718
            'support_url' => $data['support_info']['url'] ?? null,
719
            'legal_notice' => $data['legal_notice'] ?? null,
720
            'directurl' => self::STEAM_STORE_URL . $appId,
721
        ];
722
    }
723
724
    /**
725
     * Check if the app is a game (not DLC, video, etc.).
726
     */
727
    protected function isGameType(array $data): bool
728
    {
729
        $type = $data['type'] ?? '';
730
        return in_array($type, ['game', 'demo'], true);
731
    }
732
733
    /**
734
     * Make a rate-limited request to Steam API.
735
     */
736
    protected function makeRequest(string $url, array $params = []): ?array
737
    {
738
        return RateLimiter::attempt(
739
            self::RATE_LIMIT_KEY,
740
            self::REQUESTS_PER_MINUTE,
741
            function () use ($url, $params) {
742
                if ($this->apiKey !== null) {
743
                    $params['key'] = $this->apiKey;
744
                }
745
746
                try {
747
                    return Http::timeout(15)->get($url, $params)->json();
748
                } catch (\Exception $e) {
749
                    Log::warning('SteamService: API request failed', [
750
                        'url' => $url,
751
                        'error' => $e->getMessage()
752
                    ]);
753
                    return null;
754
                }
755
            },
756
            self::DECAY_SECONDS
757
        );
758
    }
759
760
    // ========================================
761
    // Title Matching & Normalization
762
    // ========================================
763
764
    /**
765
     * Clean a release title for searching.
766
     */
767
    public function cleanTitle(string $title): string
768
    {
769
        $title = trim($title);
770
        if ($title === '') {
771
            return '';
772
        }
773
774
        // URL decode
775
        $title = urldecode($title);
776
777
        // Remove file extensions
778
        $title = (string) preg_replace('/\.(zip|rar|7z|iso|nfo|sfv|exe|mkv|mp4|avi)$/i', '', $title);
779
780
        // Remove bracketed content
781
        $title = (string) preg_replace('/\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/u', ' ', $title);
782
783
        // Remove scene groups at end
784
        $groupPattern = '/\s*[-_]\s*(' . implode('|', array_map('preg_quote', self::SCENE_GROUPS)) . ')\s*$/i';
785
        $title = (string) preg_replace($groupPattern, '', $title);
786
787
        // Remove edition tags (multi-word patterns first)
788
        $editionPatterns = [
789
            '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i',
790
            '/\bGOTY[\s._-]*(Edition)?\b/i',
791
            '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i',
792
            '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i',
793
            '/\bEdition\b/i', // Remove standalone "Edition"
794
        ];
795
        foreach ($editionPatterns as $pattern) {
796
            $title = (string) preg_replace($pattern, ' ', $title);
797
        }
798
799
        // Remove standalone edition tags
800
        foreach (self::EDITION_TAGS as $tag) {
801
            // Skip tags already handled above
802
            if (stripos($tag, 'EDITION') !== false) {
803
                continue;
804
            }
805
            $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\b/i', ' ', $title);
806
        }
807
808
        // Remove release tags
809
        foreach (self::RELEASE_TAGS as $tag) {
810
            $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\d*\b/i', ' ', $title);
811
        }
812
813
        // Remove DLCs tag (common in scene releases)
814
        $title = (string) preg_replace('/\b(Incl(?:uding)?\.?\s*)?DLCs?\b/i', ' ', $title);
815
816
        // Remove version numbers (v1.2.3, 1.2.3.4, etc.)
817
        $title = (string) preg_replace('/\bv?\d+(?:\.\d+){2,}\b/i', ' ', $title);
818
819
        // Replace separators with spaces
820
        $title = (string) preg_replace('/[._+\-]+/', ' ', $title);
821
822
        // Clean up
823
        $title = (string) preg_replace('/\s+/', ' ', $title);
824
        $title = trim($title, " \t\n\r\0\x0B-_");
825
826
        return $title;
827
    }
828
829
    /**
830
     * Generate query variants for better matching.
831
     *
832
     * @return array<string>
833
     */
834
    protected function generateQueryVariants(string $title): array
835
    {
836
        $variants = [$title];
837
838
        // Without edition tags (already cleaned, but be sure)
839
        $stripped = $this->stripEditionTags($title);
840
        if ($stripped !== $title && $stripped !== '') {
841
            $variants[] = $stripped;
842
        }
843
844
        // Without parentheses content
845
        $noParen = (string) preg_replace('/\s*\([^)]*\)\s*/', ' ', $stripped);
846
        $noParen = trim(preg_replace('/\s+/', ' ', $noParen) ?? '');
847
        if ($noParen !== $stripped && $noParen !== '') {
848
            $variants[] = $noParen;
849
        }
850
851
        // Left side of colon (base title)
852
        if (str_contains($noParen, ':')) {
853
            $left = trim(explode(':', $noParen, 2)[0]);
854
            if ($left !== '' && $left !== $noParen) {
855
                $variants[] = $left;
856
            }
857
        }
858
859
        // Normalized version
860
        $normalized = $this->normalizeTitle($noParen);
861
        if ($normalized !== $noParen && $normalized !== '') {
862
            $variants[] = $normalized;
863
        }
864
865
        // De-duplicate while preserving order
866
        $unique = [];
867
        $seen = [];
868
        foreach ($variants as $v) {
869
            $v = trim($v);
870
            if ($v === '') {
871
                continue;
872
            }
873
            $lower = mb_strtolower($v);
874
            if (!isset($seen[$lower])) {
875
                $seen[$lower] = true;
876
                $unique[] = $v;
877
            }
878
        }
879
880
        return $unique;
881
    }
882
883
    /**
884
     * Normalize a title for comparison.
885
     */
886
    protected function normalizeTitle(string $title): string
887
    {
888
        $s = mb_strtolower($title);
889
890
        // Replace separators
891
        $s = (string) preg_replace('/[._\-+]+/u', ' ', $s);
892
893
        // Convert roman numerals
894
        $s = $this->replaceRomanNumerals($s);
895
896
        // Remove common noise
897
        $noise = array_merge(
898
            array_map('strtolower', self::SCENE_GROUPS),
899
            array_map('strtolower', self::EDITION_TAGS),
900
            array_map('strtolower', self::RELEASE_TAGS),
901
            ['pc', 'win', 'windows', 'x86', 'x64', 'x32']
902
        );
903
        $noisePattern = '/\b(' . implode('|', array_map(fn($w) => preg_quote($w, '/'), $noise)) . ')\b/u';
904
        $s = (string) preg_replace($noisePattern, ' ', $s);
905
906
        // Remove non-alphanumeric
907
        $s = (string) preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $s);
908
        $s = (string) preg_replace('/\s+/u', ' ', $s);
909
        $s = trim($s);
910
911
        // Remove leading articles
912
        $s = (string) preg_replace('/^(the|a|an)\s+/u', '', $s);
913
914
        return $s;
915
    }
916
917
    /**
918
     * Strip edition tags from a title.
919
     */
920
    protected function stripEditionTags(string $title): string
921
    {
922
        // Remove compound edition phrases first
923
        $compoundPatterns = [
924
            '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i',
925
            '/\bGOTY[\s._-]*(Edition)?\b/i',
926
            '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i',
927
            '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i',
928
            '/\bEdition\b/i', // Remove standalone "Edition"
929
        ];
930
        foreach ($compoundPatterns as $pattern) {
931
            $title = (string) preg_replace($pattern, ' ', $title);
932
        }
933
934
        // Remove remaining individual edition tags
935
        foreach (self::EDITION_TAGS as $tag) {
936
            $title = (string) preg_replace('/\s*[-_]?\s*' . preg_quote($tag, '/') . '\s*/i', ' ', $title);
937
        }
938
        return trim(preg_replace('/\s+/', ' ', $title) ?? '');
939
    }
940
941
    /**
942
     * Score a candidate title against the original search term.
943
     */
944
    protected function scoreTitle(string $candidate, string $original): float
945
    {
946
        $normCand = $this->normalizeTitle($candidate);
947
        $normOrig = $this->normalizeTitle($original);
948
949
        if ($normCand === '' || $normOrig === '') {
950
            return 0.0;
951
        }
952
953
        // Perfect match
954
        if ($normCand === $normOrig) {
955
            return 100.0;
956
        }
957
958
        // Token-based scoring
959
        $tokensCand = $this->tokenize($normCand);
960
        $tokensOrig = $this->tokenize($normOrig);
961
962
        // Token containment check
963
        $intersect = count(array_intersect($tokensCand, $tokensOrig));
964
        $union = count(array_unique(array_merge($tokensCand, $tokensOrig)));
965
        $jaccard = $union > 0 ? ($intersect / $union) : 0.0;
966
967
        // Levenshtein similarity
968
        $lev = levenshtein($normCand, $normOrig);
969
        $maxLen = max(strlen($normCand), strlen($normOrig));
970
        $levSim = $maxLen > 0 ? (1.0 - ($lev / $maxLen)) : 0.0;
971
972
        // Prefix bonus
973
        $prefixBoost = 0.0;
974
        if (str_starts_with($normCand, $normOrig) || str_starts_with($normOrig, $normCand)) {
975
            $prefixBoost = 0.15;
976
        }
977
978
        // If all tokens of one are in the other
979
        $candInOrig = empty(array_diff($tokensCand, $tokensOrig));
980
        $origInCand = empty(array_diff($tokensOrig, $tokensCand));
981
        if ($candInOrig || $origInCand) {
982
            $shortCount = min(count($tokensCand), count($tokensOrig));
983
            $longCount = max(count($tokensCand), count($tokensOrig));
984
            $coverage = $longCount > 0 ? ($shortCount / $longCount) : 0.0;
985
            if ($coverage >= 0.8) {
986
                return 100.0;
987
            }
988
        }
989
990
        // Combined score
991
        $score = ($jaccard * 0.6 + $levSim * 0.4 + $prefixBoost) * 100.0;
992
993
        return max(0.0, min(100.0, $score));
994
    }
995
996
    /**
997
     * Tokenize a string.
998
     */
999
    protected function tokenize(string $s): array
1000
    {
1001
        $parts = preg_split('/\s+/u', mb_strtolower($s)) ?: [];
1002
        $seen = [];
1003
        $out = [];
1004
        foreach ($parts as $p) {
1005
            $p = trim($p);
1006
            if ($p === '' || isset($seen[$p])) {
1007
                continue;
1008
            }
1009
            $seen[$p] = true;
1010
            $out[] = $p;
1011
        }
1012
        return $out;
1013
    }
1014
1015
    /**
1016
     * Replace roman numerals with arabic numbers.
1017
     */
1018
    protected function replaceRomanNumerals(string $s): string
1019
    {
1020
        $map = [
1021
            'xx' => '20', 'xix' => '19', 'xviii' => '18', 'xvii' => '17', 'xvi' => '16',
1022
            'xv' => '15', 'xiv' => '14', 'xiii' => '13', 'xii' => '12', 'xi' => '11',
1023
            'x' => '10', 'ix' => '9', 'viii' => '8', 'vii' => '7', 'vi' => '6',
1024
            'v' => '5', 'iv' => '4', 'iii' => '3', 'ii' => '2',
1025
        ];
1026
1027
        // Don't replace standalone 'i' as it's too common in titles
1028
        foreach ($map as $roman => $arabic) {
1029
            $s = (string) preg_replace('/\b' . $roman . '\b/ui', $arabic, $s);
1030
        }
1031
1032
        return $s;
1033
    }
1034
1035
    /**
1036
     * Build a SQL LIKE pattern from a search term.
1037
     */
1038
    protected function buildLikePattern(string $term): string
1039
    {
1040
        $normalized = $this->normalizeTitle($term);
1041
        $pattern = preg_replace('/\s+/', '%', $normalized);
1042
        $pattern = trim($pattern ?? '');
1043
1044
        return $pattern === '' ? '%' : '%' . $pattern . '%';
1045
    }
1046
1047
    /**
1048
     * Clear all cached data.
1049
     */
1050
    public function clearCache(): void
1051
    {
1052
        // Note: This is a placeholder. In production, you'd use tagged caches
1053
        // or implement specific cache key management.
1054
        Log::info('SteamService: Cache clear requested');
1055
    }
1056
}
1057
1058