'xbox 360', 'xbox360' => 'xbox 360', 'xbla' => 'xbox 360', 'xbox one' => 'xbox one', 'xboxone' => 'xbox one', 'x-box' => 'xbox', 'xbox' => 'xbox', 'dsi' => 'nintendo ds', 'nds' => 'nintendo ds', '3ds' => 'nintendo 3ds', 'ps2' => 'playstation2', 'ps3' => 'playstation 3', 'ps4' => 'playstation 4', 'psp' => 'sony psp', 'psvita' => 'playstation vita', 'psx' => 'playstation', 'psx2psp' => 'playstation', 'wiiu' => 'nintendo wii u', 'wii' => 'nintendo wii', 'ngc' => 'gamecube', 'gc' => 'gamecube', 'n64' => 'nintendo 64', 'nes' => 'nintendo nes', 'super nintendo' => 'snes', 'nintendo super nes' => 'snes', 'snes' => 'snes', ]; /** * Check if IGDB is configured. */ public function isConfigured(): bool { return config('igdb.credentials.client_id') !== '' && config('igdb.credentials.client_secret') !== ''; } /** * Search for a game by title and return the best matching Game object. */ public function search(string $title): ?Game { if (! $this->isConfigured() || empty($title)) { return null; } $cacheKey = 'igdb_search:'.md5(mb_strtolower($title)); // Check failed lookup cache if (Cache::has("igdb_search_failed:{$cacheKey}")) { Log::debug('IGDBService: Skipping previously failed search', ['title' => $title]); return null; } // Check successful search cache $cached = Cache::get($cacheKey); if ($cached !== null) { Log::debug('IGDBService: Using cached search result', ['title' => $title]); return $cached instanceof Game ? $cached : null; } $game = $this->searchWithStrategies($title, self::PC_PLATFORM_IDS); if ($game !== null) { Cache::put($cacheKey, $game, self::GAME_CACHE_TTL); Log::info('IGDBService: Found match', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound } else { Cache::put("igdb_search_failed:{$cacheKey}", true, self::FAILED_LOOKUP_CACHE_TTL); Log::debug('IGDBService: No match found', ['title' => $title]); } return $game; } /** * Search for a console game using an optional platform hint. */ public function searchConsole(string $title, string $platformHint): ?Game { if (! $this->isConfigured() || $title === '') { return null; } $cacheKey = 'igdb_console_search:'.md5(mb_strtolower($title.'|'.$platformHint)); if (Cache::has("igdb_console_search_failed:{$cacheKey}")) { Log::debug('IGDBService: Skipping previously failed console search', [ 'title' => $title, 'platform' => $platformHint, ]); return null; } $cached = Cache::get($cacheKey); if ($cached instanceof Game) { Log::debug('IGDBService: Using cached console search result', [ 'title' => $title, 'platform' => $platformHint, ]); return $cached; } $game = $this->searchWithStrategies($title, null, $platformHint); if ($game instanceof Game) { Cache::put($cacheKey, $game, self::GAME_CACHE_TTL); return $game; } Cache::put("igdb_console_search_failed:{$cacheKey}", true, self::FAILED_LOOKUP_CACHE_TTL); return null; } /** * Get complete game details from a Game object. * * @return array * title: string, * asin: string, * review: string, * coverurl: string, * releasedate: string, * esrb: string, * url: string, * backdropurl: string, * trailer: string, * publisher: string, * developer: string, * genres: array, * }|false * @return array */ public function getGameDetails(Game $game): array|false { if (empty($game->name)) { return false; } $genreName = ''; return $this->buildGameData($game, $genreName); } /** * Search IGDB using multiple strategies for better match rates. */ protected function searchWithStrategies(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { // Strategy 1: Exact name search with PC platform filter $game = $this->searchExact($title, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Exact match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound return $game; } // Strategy 2: Fuzzy search using IGDB's search endpoint $game = $this->searchFuzzy($title, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Fuzzy match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound return $game; } // Strategy 3: Search without special characters $cleanTitle = preg_replace('/[^a-zA-Z0-9\s]/', '', $title); if ($cleanTitle !== $title && $cleanTitle !== '') { $game = $this->searchFuzzy($cleanTitle, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Clean title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound return $game; } } // Strategy 4: Try with common subtitle patterns removed $baseTitle = $this->extractBaseTitle($title); if ($baseTitle !== $title && $baseTitle !== $cleanTitle && $baseTitle !== '') { $game = $this->searchFuzzy($baseTitle, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Base title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound return $game; } } return null; } /** * Exact name search on IGDB with PC platform filter. */ protected function searchExact(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { try { $results = RateLimiter::attempt( self::RATE_LIMIT_KEY, self::REQUESTS_PER_MINUTE, function () use ($platformIds, $title) { $query = Game::where('name', $title) ->with($this->getGameRelations()) ->orderByDesc('aggregated_rating_count') ->limit(10); if ($platformIds !== null) { $query->whereIn('platforms', $platformIds); } return $query->get(); }, self::DECAY_SECONDS ); if ($results === true || empty($results)) { return null; } return $this->findBestMatch($results, $title, $platformHint); } catch (\Exception $e) { Log::warning('IGDBService: Exact search error', ['error' => $e->getMessage()]); return null; } } /** * Fuzzy search on IGDB using the search endpoint. */ protected function searchFuzzy(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { try { $results = RateLimiter::attempt( self::RATE_LIMIT_KEY, self::REQUESTS_PER_MINUTE, function () use ($platformIds, $title) { $query = Game::search($title) ->where('category', 0) // Main game only (not DLC, expansion, etc.) ->with($this->getGameRelations()) ->orderByDesc('aggregated_rating_count') ->limit(10); if ($platformIds !== null) { $query->whereIn('platforms', $platformIds); } return $query->get(); }, self::DECAY_SECONDS ); if ($results === true || empty($results)) { return null; } return $this->findBestMatch($results, $title, $platformHint); } catch (\Exception $e) { Log::warning('IGDBService: Fuzzy search error', ['error' => $e->getMessage()]); return null; } } /** * Get relations to load with IGDB queries. * * @return array */ protected function getGameRelations(): array { return [ 'cover' => ['url', 'image_id'], 'screenshots' => ['url', 'image_id'], 'artworks' => ['url', 'image_id'], 'videos' => ['video_id', 'name'], 'involved_companies' => ['company', 'publisher', 'developer'], 'genres' => ['name'], 'themes' => ['name'], 'game_modes' => ['name'], 'player_perspectives' => ['name'], 'age_ratings' => ['rating', 'category'], 'websites' => ['url', 'category'], 'platforms' => ['name', 'abbreviation'], 'release_dates' => ['date', 'platform', 'human'], ]; } /** * Find the best matching game from results. */ protected function findBestMatch(mixed $results, string $title, ?string $platformHint = null): ?Game { $bestMatch = null; $bestScore = 0; $normalizedQuery = $this->normalizeForMatch($title); foreach ($results as $game) { $normalizedName = $this->normalizeForMatch($game->name); $score = $this->computeSimilarity($normalizedQuery, $normalizedName); // Boost score for games with more ratings (more popular/verified) if (isset($game->aggregated_rating_count) && $game->aggregated_rating_count > 10) { $score += min(5, $game->aggregated_rating_count / 100); } // Boost score for games with covers (more complete data) if (isset($game->cover)) { $score += 2; } if ($platformHint !== null && $platformHint !== '') { $platformScore = $this->getPlatformMatchScore($game, $platformHint); $score += $platformScore > 0 ? $platformScore : -10; } // Check alternative names if available if (isset($game->alternative_names)) { foreach ($game->alternative_names as $altName) { $altScore = $this->computeSimilarity($normalizedQuery, $this->normalizeForMatch($altName['name'] ?? '')); if ($altScore > $score) { $score = $altScore; } } } if ($score >= self::MATCH_THRESHOLD && $score > $bestScore) { $bestMatch = $game; $bestScore = $score; } } if ($bestMatch !== null) { Log::debug('IGDBService: Best match selected', [ 'query' => $title, 'matched' => $bestMatch->name, 'score' => $bestScore, ]); } return $bestMatch; } /** * Build console-specific game data from an IGDB Game model. * * @return array */ public function buildConsoleData(Game $game, string $platformHint): array { $genres = $this->extractGenres($game); $publishers = $this->extractCompanyNames($game, 'publisher'); return [ 'title' => $game->name, 'asin' => (string) $game->id, 'review' => (string) ($game->summary ?? ''), 'coverurl' => $this->getImageUrl($game->cover ?? null, 'cover_big'), 'releasedate' => $this->getReleaseDate($game), 'esrb' => isset($game->aggregated_rating) && is_numeric($game->aggregated_rating) ? round((float) $game->aggregated_rating).'%' : $this->getAgeRating($game), 'url' => $game->url ?? '', 'publisher' => ! empty($publishers) ? implode(',', $publishers) : 'Unknown', 'platform' => $this->resolvePlatformName($game, $platformHint), 'consolegenre' => ! empty($genres) ? implode(',', $genres) : 'Unknown', 'salesrank' => '', ]; } /** * Build game data array from IGDB Game model. * * @return array */ public function buildGameData(Game $game, string &$genreName): array { // Extract publishers and developers $publishers = []; $developers = []; if (! empty($game->involved_companies)) { $involvedCompanies = $game->involved_companies; if ($involvedCompanies instanceof Collection) { $involvedCompanies = $involvedCompanies->toArray(); } foreach ($involvedCompanies as $company) { $isPublisher = is_array($company) ? ($company['publisher'] ?? false) : ($company->publisher ?? false); $isDeveloper = is_array($company) ? ($company['developer'] ?? false) : ($company->developer ?? false); $companyId = is_array($company) ? ($company['company'] ?? null) : ($company->company ?? null); if ($isPublisher === true && $companyId) { $companyData = Company::find($companyId); if ($companyData) { $publishers[] = $companyData->name; } } if ($isDeveloper === true && $companyId) { $companyData = Company::find($companyId); if ($companyData) { $developers[] = $companyData->name; } } } } // Extract genres $genres = $this->extractGenres($game); $genreName = $this->matchGenre(implode(',', array_filter($genres))); // Get cover and backdrop URLs $coverUrl = $this->getImageUrl($game->cover ?? null, 'cover_big'); $backdropUrl = $this->getBackdropUrl($game); // Get trailer URL $trailerUrl = $this->getTrailerUrl($game); // Get rating $esrb = $this->getAgeRating($game); // Get release date for PC $releaseDate = $this->getReleaseDate($game); // Get game URL $gameUrl = $game->url ?? ('https://www.igdb.com/games/'.($game->slug ?? $game->id)); // @phpstan-ignore property.notFound // Build review text $review = $this->buildReview($game, $developers); // @phpstan-ignore argument.type Log::info('IGDBService: Game data built', [ 'title' => $game->name, // @phpstan-ignore property.notFound 'id' => $game->id, // @phpstan-ignore property.notFound 'has_cover' => ! empty($coverUrl), 'has_backdrop' => ! empty($backdropUrl), 'genres' => $genres, ]); return [ 'title' => $game->name, // @phpstan-ignore property.notFound 'asin' => 'igdb-'.$game->id, // @phpstan-ignore property.notFound 'review' => $review, 'coverurl' => $coverUrl, 'releasedate' => $releaseDate, 'esrb' => $esrb, 'url' => $gameUrl, 'backdropurl' => $backdropUrl, 'trailer' => $trailerUrl, 'publisher' => ! empty($publishers) ? implode(', ', array_slice($publishers, 0, 3)) : 'Unknown', 'developer' => ! empty($developers) ? implode(', ', array_slice($developers, 0, 3)) : '', 'genres' => $genres, ]; } /** * Extract genres from game data. * * @return array */ protected function extractGenres(Game $game): array { $genres = []; if (! empty($game->genres)) { $gameGenres = $game->genres; if ($gameGenres instanceof Collection) { $gameGenres = $gameGenres->toArray(); } foreach ($gameGenres as $genre) { $genres[] = is_array($genre) ? ($genre['name'] ?? '') : ($genre->name ?? ''); } } // Fall back to themes if no genres if (empty($genres) && ! empty($game->themes)) { $gameThemes = $game->themes; if ($gameThemes instanceof Collection) { $gameThemes = $gameThemes->toArray(); } foreach ($gameThemes as $theme) { $genres[] = is_array($theme) ? ($theme['name'] ?? '') : ($theme->name ?? ''); } } return array_filter($genres); } /** * @return array */ protected function extractCompanyNames(Game $game, string $flag): array { $names = []; if (empty($game->involved_companies)) { return $names; } $involvedCompanies = $game->involved_companies; if ($involvedCompanies instanceof Collection) { $involvedCompanies = $involvedCompanies->toArray(); } foreach ($involvedCompanies as $company) { $isMatch = is_array($company) ? ($company[$flag] ?? false) : ($company->{$flag} ?? false); $companyId = is_array($company) ? ($company['company'] ?? null) : ($company->company ?? null); if ($isMatch !== true || ! $companyId) { continue; } $companyData = Company::find($companyId); if ($companyData !== null && ! empty($companyData->name)) { $names[] = $companyData->name; } } return array_values(array_unique($names)); } protected function resolvePlatformName(Game $game, string $platformHint): string { $normalizedHint = $this->normalizePlatformHint($platformHint); $fallback = ''; foreach ((array) ($game->platforms ?? []) as $platform) { $name = is_array($platform) ? (string) ($platform['name'] ?? '') : (string) ($platform->name ?? ''); $abbreviation = is_array($platform) ? (string) ($platform['abbreviation'] ?? '') : (string) ($platform->abbreviation ?? ''); if ($fallback === '' && $name !== '') { $fallback = $name; } foreach ([$name, $abbreviation] as $candidate) { if ($candidate !== '' && $this->normalizePlatformHint($candidate) === $normalizedHint) { return $name !== '' ? $name : $candidate; } } } return $fallback; } protected function getPlatformMatchScore(Game $game, string $platformHint): int { $normalizedHint = $this->normalizePlatformHint($platformHint); if ($normalizedHint === '') { return 0; } foreach ((array) ($game->platforms ?? []) as $platform) { $name = is_array($platform) ? (string) ($platform['name'] ?? '') : (string) ($platform->name ?? ''); $abbreviation = is_array($platform) ? (string) ($platform['abbreviation'] ?? '') : (string) ($platform->abbreviation ?? ''); foreach ([$name, $abbreviation] as $candidate) { if ($candidate === '') { continue; } if ($this->normalizePlatformHint($candidate) === $normalizedHint) { return 15; } } } return 0; } protected function normalizePlatformHint(string $platform): string { $normalized = mb_strtolower(trim($platform)); return self::PLATFORM_ALIASES[$normalized] ?? $normalized; } /** * Get properly formatted IGDB image URL. * * @param array $imageData * @return array, mixed> */ public function getImageUrl(array|object|null $imageData, string $size = 'cover_big'): string { if (empty($imageData)) { return ''; } if (is_object($imageData)) { $imageId = $imageData->image_id ?? ($imageData->imageId ?? null); $url = $imageData->url ?? null; $imageData = [ 'image_id' => $imageId, 'url' => $url, ]; } if (! empty($imageData['image_id'])) { return 'https://images.igdb.com/igdb/image/upload/t_'.$size.'/'.$imageData['image_id'].'.jpg'; } if (! empty($imageData['url'])) { $url = $imageData['url']; if (str_starts_with($url, '//')) { $url = 'https:'.$url; } return preg_replace('/t_[a-z0-9_]+/', 't_'.$size, $url); } return ''; } /** * Get backdrop URL from artworks or screenshots. */ protected function getBackdropUrl(Game $game): string { if (! empty($game->artworks)) { $artworks = $game->artworks; $firstArtwork = ($artworks instanceof Collection) ? $artworks->first() : ($artworks[0] ?? null); $url = $this->getImageUrl($firstArtwork, '1080p'); if (! empty($url)) { return $url; } } if (! empty($game->screenshots)) { $screenshots = $game->screenshots; $firstScreenshot = ($screenshots instanceof Collection) ? $screenshots->first() : ($screenshots[0] ?? null); return $this->getImageUrl($firstScreenshot, '1080p'); } return ''; } /** * Get trailer URL from videos. */ protected function getTrailerUrl(Game $game): string { if (! empty($game->videos)) { $videos = $game->videos; if ($videos instanceof Collection) { $videos = $videos->toArray(); } foreach ($videos as $video) { $videoId = is_array($video) ? ($video['video_id'] ?? null) : ($video->video_id ?? null); if ($videoId) { return 'https://www.youtube.com/watch?v='.$videoId; } } } return ''; } /** * Get age rating string from IGDB age ratings. */ protected function getAgeRating(Game $game): string { $ageRatings = $game->age_ratings ?? []; if ($ageRatings instanceof Collection) { $ageRatings = $ageRatings->toArray(); } if (empty($ageRatings)) { return 'Not Rated'; } // ESRB ratings map $esrbMap = [ 6 => 'RP (Rating Pending)', 7 => 'EC (Early Childhood)', 8 => 'E (Everyone)', 9 => 'E10+ (Everyone 10+)', 10 => 'T (Teen)', 11 => 'M (Mature 17+)', 12 => 'AO (Adults Only)', ]; // PEGI ratings map $pegiMap = [ 1 => 'PEGI 3', 2 => 'PEGI 7', 3 => 'PEGI 12', 4 => 'PEGI 16', 5 => 'PEGI 18', ]; foreach ($ageRatings as $rating) { $category = is_array($rating) ? ($rating['category'] ?? 0) : ($rating->category ?? 0); $ratingValue = is_array($rating) ? ($rating['rating'] ?? 0) : ($rating->rating ?? 0); // Prefer ESRB if ($category === 1 && isset($esrbMap[$ratingValue])) { return $esrbMap[$ratingValue]; } // Fall back to PEGI if ($category === 2 && isset($pegiMap[$ratingValue])) { return $pegiMap[$ratingValue]; } } return 'Not Rated'; } /** * Get PC release date from IGDB game data. */ protected function getReleaseDate(Game $game): string { if (! empty($game->release_dates)) { foreach ($game->release_dates as $release) { if (isset($release['platform']) && $release['platform'] === 6 && isset($release['date'])) { return Carbon::createFromTimestamp($release['date'])->format('Y-m-d'); } } if (isset($game->release_dates[0]['date'])) { return Carbon::createFromTimestamp($game->release_dates[0]['date'])->format('Y-m-d'); } } if (isset($game->first_release_date)) { if ($game->first_release_date instanceof Carbon) { return $game->first_release_date->format('Y-m-d'); } if (is_numeric($game->first_release_date)) { return Carbon::createFromTimestamp($game->first_release_date)->format('Y-m-d'); } } return now()->format('Y-m-d'); } /** * Build review/summary text. * * @param array $developers */ protected function buildReview(Game $game, array $developers): string { $review = $game->summary ?? ''; if (empty($review) && isset($game->storyline)) { $review = $game->storyline; } $additionalInfo = []; if (! empty($developers)) { $additionalInfo[] = 'Developer: '.implode(', ', array_slice($developers, 0, 3)); } if (! empty($game->game_modes)) { $gameModes = $game->game_modes; if ($gameModes instanceof Collection) { $modes = $gameModes->map(fn ($m) => is_array($m) ? ($m['name'] ?? '') : ($m->name ?? ''))->toArray(); } else { $modes = array_map(fn ($m) => $m['name'] ?? '', $gameModes); } $additionalInfo[] = 'Modes: '.implode(', ', array_filter($modes)); } if (! empty($additionalInfo) && ! empty($review)) { $review .= "\n\n".implode("\n", $additionalInfo); } return $review; } /** * Extract base title by removing common subtitle patterns. */ protected function extractBaseTitle(string $title): string { $patterns = [ '/\s*[-:]\s+.*$/', '/\s+(?:Episode|Chapter|Part)\s+\d+.*/i', '/\s+(?:Vol(?:ume)?\.?\s*\d+).*/i', '/\s+\d+$/', ]; $baseTitle = $title; foreach ($patterns as $pattern) { $baseTitle = preg_replace($pattern, '', $baseTitle); } return trim($baseTitle); } /** * Normalize title for matching. */ protected function normalizeForMatch(string $title): string { $t = mb_strtolower($title); $t = (string) preg_replace('/\b(game of the year|goty|definitive edition|deluxe edition|ultimate edition|complete edition|remastered|hd remaster|directors? cut|anniversary edition|update|patch|hotfix|incl(?:uding)? dlcs?|dlcs?|repack|rip|iso|crack(?:fix)?|beta|alpha)\b/i', ' ', $t); $t = (string) preg_replace('/\b(pc|gog|steam|x64|x86|win64|win32|mult[iy]?\d*|eng|english|fr|french|de|german|es|spanish|it|italian|pt|ptbr|portuguese|ru|russian|pl|polish|tr|turkish|nl|dutch|se|swedish|no|norwegian|da|danish|fi|finnish|jp|japanese|cn|chs|cht|ko|korean)\b/i', ' ', $t); $t = (string) preg_replace('/[^a-z0-9]+/i', ' ', $t); $t = trim(preg_replace('/\s{2,}/', ' ', $t)); return $t; } /** * Compute similarity between two strings. */ protected function computeSimilarity(string $a, string $b): float { if ($a === $b) { return 100.0; } $percent = 0.0; similar_text($a, $b, $percent); $levScore = 0.0; $len = max(strlen($a), strlen($b)); if ($len > 0) { $dist = levenshtein($a, $b); if ($dist >= 0) { $levScore = (1 - ($dist / $len)) * 100.0; } } return max($percent, $levScore); } /** * Match genre string to known genres. */ public function matchGenre(string $genre): string { $genreName = ''; $a = str_replace('-', ' ', $genre); $tmpGenre = explode(',', $a); foreach ($tmpGenre as $tg) { $genreMatch = $this->isKnownGenre(ucwords(trim($tg))); if ($genreMatch !== false) { $genreName = (string) $genreMatch; break; } if (empty($genreName) && ! empty($tmpGenre[0])) { $genreName = trim($tmpGenre[0]); } } return $genreName; } /** * Check if genre is in known genres list. */ public function isKnownGenre(string $gameGenre): bool|string { $knownGenres = [ 'Action', 'Adventure', 'Arcade', 'Board Games', 'Cards', 'Casino', 'Flying', 'Puzzle', 'Racing', 'Rhythm', 'Role-Playing', 'RPG', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Shooter', 'FPS', 'Horror', 'Survival', 'Sandbox', 'Open World', 'Platformer', 'Fighting', 'Stealth', 'MMO', 'MMORPG', 'Battle Royale', 'Roguelike', 'Roguelite', 'Metroidvania', 'Visual Novel', 'Point & Click', 'Management', 'City Builder', 'Tower Defense', 'Turn-Based', 'Real-Time', 'Educational', 'Music', 'Party', 'Indie', 'Hack and Slash', 'Souls-like', 'JRPG', 'ARPG', 'Tactical', ]; return in_array($gameGenre, $knownGenres, true) ? $gameGenre : false; } /** * Clear lookup caches. */ public function clearCache(): void { Log::info('IGDBService: Cache clear requested'); } }