diff --git a/Blacklight/Games.php b/Blacklight/Games.php
index d5685af77..bc9550d57 100755
--- a/Blacklight/Games.php
+++ b/Blacklight/Games.php
@@ -25,24 +25,24 @@ use MarcReichel\IGDBLaravel\Models\Game;
*/
class Games
{
- protected const GAME_MATCH_PERCENTAGE = 85;
+ protected const int GAME_MATCH_PERCENTAGE = 85;
// Rate limiting constants
- protected const STEAM_RATE_LIMIT_KEY = 'steam_api_rate_limit';
- protected const IGDB_RATE_LIMIT_KEY = 'igdb_api_rate_limit';
- protected const STEAM_REQUESTS_PER_MINUTE = 10;
- protected const IGDB_REQUESTS_PER_MINUTE = 4;
+ protected const string STEAM_RATE_LIMIT_KEY = 'steam_api_rate_limit';
+ protected const string IGDB_RATE_LIMIT_KEY = 'igdb_api_rate_limit';
+ protected const int STEAM_REQUESTS_PER_MINUTE = 10;
+ protected const int IGDB_REQUESTS_PER_MINUTE = 4;
// Cache TTL in seconds
- protected const GAME_CACHE_TTL = 86400; // 24 hours
- protected const FAILED_LOOKUP_CACHE_TTL = 3600; // 1 hour for failed lookups
+ protected const int GAME_CACHE_TTL = 86400; // 24 hours
+ protected const int FAILED_LOOKUP_CACHE_TTL = 3600; // 1 hour for failed lookups
// Retry configuration
- protected const MAX_RETRIES = 3;
- protected const RETRY_DELAY_SECONDS = 5;
+ protected const int MAX_RETRIES = 3;
+ protected const int RETRY_DELAY_SECONDS = 5;
// Expanded to cover many popular scene/p2p groups and tag forms.
- protected const SCENE_GROUPS = [
+ protected const array SCENE_GROUPS = [
'CODEX', 'PLAZA', 'GOG', 'CPY', 'HOODLUM', 'EMPRESS', 'RUNE', 'TENOKE', 'FLT',
'RELOADED', 'SKIDROW', 'PROPHET', 'RAZOR1911', 'CORE', 'REFLEX', 'P2P', 'GOLDBERG',
'DARKSIDERS', 'TINYISO', 'DOGE', 'ANOMALY', 'ELAMIGOS', 'FITGIRL', 'DODI', 'XATAB',
@@ -51,7 +51,7 @@ class Games
'PROPHET', 'ALI213', 'FLTDOX', '3DMGAME', 'POSTMORTEM', 'VACE', 'ROGUE', 'OUTLAWS',
];
- protected const GAMES_TITLE_PARSE_REGEX =
+ protected const string GAMES_TITLE_PARSE_REGEX =
'#(?P
[\w\s\.]+)(-(?PFLT|RELOADED|Elamigos|SKIDROW|PROPHET|RAZOR1911|CORE|REFLEX))?\s?(\s*(\?('.
'(?PPROPER|MULTI\d|RETAIL|CRACK(FIX)?|ISO|(RE)?(RIP|PACK))|(?P(19|20)\d{2})|V\s?'.
'(?P(\d+\.)+\d+)|(-\s)?(?P=relgrp))\)?)\s?)*\s?(\.\w{2,4})?#i';
@@ -829,7 +829,8 @@ class Games
}
/**
- * Fetch game from IGDB with rate limiting.
+ * Fetch game from IGDB with rate limiting and improved search.
+ * Uses multiple search strategies for better matching.
*
* @return array|false
*/
@@ -837,81 +838,448 @@ class Games
{
$this->_classUsed = 'IGDB';
- $result = RateLimiter::attempt(
- self::IGDB_RATE_LIMIT_KEY,
- self::IGDB_REQUESTS_PER_MINUTE,
- function () use ($title) {
- return Game::where('name', $title)->get();
- },
- 60
- );
+ // Try multiple search strategies
+ $game = $this->searchIGDBWithStrategies($title);
- if (empty($result)) {
- $this->colorCli->notice('IGDB found no valid results');
+ if ($game === null) {
+ $this->colorCli->notice('IGDB found no valid results for: ' . $title);
+ Log::debug('Games: IGDB search failed', ['title' => $title]);
return false;
}
- $bestMatch = false;
- $bestMatchPct = 0;
- $normQuery = $this->normalizeForMatch($title);
+ return $this->buildGameDataFromIGDB($game, $genreName);
+ }
- foreach ($result as $res) {
- $score1 = $this->computeSimilarity($normQuery, $this->normalizeForMatch($res->name));
- if ($score1 >= self::GAME_MATCH_PERCENTAGE && $score1 > $bestMatchPct) {
- $bestMatch = $res->id;
- $bestMatchPct = $score1;
+ /**
+ * Search IGDB using multiple strategies for better match rates.
+ *
+ * @param string $title The game title to search for
+ * @return Game|null
+ */
+ protected function searchIGDBWithStrategies(string $title): ?Game
+ {
+ $normalizedTitle = $this->normalizeForMatch($title);
+
+ // Strategy 1: Exact name search with PC platform filter
+ $game = $this->searchIGDBExact($title);
+ if ($game !== null) {
+ Log::debug('Games: IGDB exact match found', ['title' => $title, 'matched' => $game->name]);
+ return $game;
+ }
+
+ // Strategy 2: Search with fuzzy matching (using IGDB's search endpoint)
+ $game = $this->searchIGDBFuzzy($title);
+ if ($game !== null) {
+ Log::debug('Games: IGDB fuzzy match found', ['title' => $title, 'matched' => $game->name]);
+ return $game;
+ }
+
+ // Strategy 3: Search without special characters
+ $cleanTitle = preg_replace('/[^a-zA-Z0-9\s]/', '', $title);
+ if ($cleanTitle !== $title) {
+ $game = $this->searchIGDBFuzzy($cleanTitle);
+ if ($game !== null) {
+ Log::debug('Games: IGDB clean title match found', ['title' => $title, 'matched' => $game->name]);
+ return $game;
}
}
- if ($bestMatch === false) {
- $this->colorCli->notice('IGDB returned no valid results');
- return false;
+ // Strategy 4: Try with common subtitle patterns removed
+ $baseTitle = $this->extractBaseTitle($title);
+ if ($baseTitle !== $title && $baseTitle !== $cleanTitle) {
+ $game = $this->searchIGDBFuzzy($baseTitle);
+ if ($game !== null) {
+ Log::debug('Games: IGDB base title match found', ['title' => $title, 'matched' => $game->name]);
+ return $game;
+ }
}
- $this->_gameResults = Game::with([
- 'cover' => ['url'],
- 'screenshots' => ['url'],
- 'involved_companies' => ['company', 'publisher'],
- 'themes',
- ])->where('id', $bestMatch)->first();
+ return null;
+ }
+ /**
+ * Exact name search on IGDB with PC platform filter.
+ */
+ protected function searchIGDBExact(string $title): ?Game
+ {
+ try {
+ $result = RateLimiter::attempt(
+ self::IGDB_RATE_LIMIT_KEY,
+ self::IGDB_REQUESTS_PER_MINUTE,
+ function () use ($title) {
+ // PC platform IDs: 6 (PC Windows), 13 (DOS), 14 (Mac), 3 (Linux)
+ return Game::where('name', $title)
+ ->whereIn('platforms', [6, 13, 14, 3])
+ ->with([
+ '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'],
+ ])
+ ->orderByDesc('aggregated_rating_count')
+ ->first();
+ },
+ 60
+ );
+
+ return $result;
+ } catch (\Exception $e) {
+ Log::warning('Games: IGDB exact search error', ['error' => $e->getMessage()]);
+ return null;
+ }
+ }
+
+ /**
+ * Fuzzy search on IGDB using the search endpoint for better matching.
+ */
+ protected function searchIGDBFuzzy(string $title): ?Game
+ {
+ try {
+ $results = RateLimiter::attempt(
+ self::IGDB_RATE_LIMIT_KEY,
+ self::IGDB_REQUESTS_PER_MINUTE,
+ function () use ($title) {
+ // Use IGDB's search which supports fuzzy matching
+ return Game::search($title)
+ ->whereIn('platforms', [6, 13, 14, 3]) // PC platforms
+ ->where('category', 0) // Main game only (not DLC, expansion, etc.)
+ ->with([
+ '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'],
+ 'release_dates' => ['date', 'platform', 'human'],
+ ])
+ ->orderByDesc('aggregated_rating_count')
+ ->limit(10)
+ ->get();
+ },
+ 60
+ );
+
+ if (empty($results)) {
+ return null;
+ }
+
+ // Find best match using similarity scoring
+ return $this->findBestIGDBMatch($results, $title);
+ } catch (\Exception $e) {
+ Log::warning('Games: IGDB fuzzy search error', ['error' => $e->getMessage()]);
+ return null;
+ }
+ }
+
+ /**
+ * Find the best matching game from IGDB results.
+ */
+ protected function findBestIGDBMatch($results, string $title): ?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;
+ }
+
+ // 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::GAME_MATCH_PERCENTAGE && $score > $bestScore) {
+ $bestMatch = $game;
+ $bestScore = $score;
+ }
+ }
+
+ if ($bestMatch !== null) {
+ Log::debug('Games: IGDB best match selected', [
+ 'query' => $title,
+ 'matched' => $bestMatch->name,
+ 'score' => $bestScore
+ ]);
+ }
+
+ return $bestMatch;
+ }
+
+ /**
+ * Extract base title by removing common subtitle patterns.
+ */
+ protected function extractBaseTitle(string $title): string
+ {
+ // Remove common subtitle separators and everything after
+ $patterns = [
+ '/\s*[-:]\s+.*$/', // "Game - Subtitle" or "Game: Subtitle"
+ '/\s+(?:Episode|Chapter|Part)\s+\d+.*/i', // Episode/Chapter/Part numbers
+ '/\s+(?:Vol(?:ume)?\.?\s*\d+).*/i', // Volume numbers
+ '/\s+\d+$/', // Trailing numbers (sequels)
+ ];
+
+ $baseTitle = $title;
+ foreach ($patterns as $pattern) {
+ $baseTitle = preg_replace($pattern, '', $baseTitle);
+ }
+
+ return trim($baseTitle);
+ }
+
+ /**
+ * Build game data array from IGDB Game model.
+ */
+ protected function buildGameDataFromIGDB(Game $game, string &$genreName): array
+ {
+ // Extract publishers and developers
$publishers = [];
- if (!empty($this->_gameResults->involved_companies)) {
- foreach ($this->_gameResults->involved_companies as $publisher) {
- if ($publisher['publisher'] === true) {
- $company = Company::find($publisher['company']);
- $publishers[] = $company['name'];
+ $developers = [];
+ if (!empty($game->involved_companies)) {
+ foreach ($game->involved_companies as $company) {
+ if (isset($company['publisher']) && $company['publisher'] === true) {
+ $companyData = Company::find($company['company']);
+ if ($companyData) {
+ $publishers[] = $companyData->name;
+ }
+ }
+ if (isset($company['developer']) && $company['developer'] === true) {
+ $companyData = Company::find($company['company']);
+ if ($companyData) {
+ $developers[] = $companyData->name;
+ }
}
}
}
+ // Extract genres (prefer genres over themes)
$genres = [];
- if (!empty($this->_gameResults->themes)) {
- foreach ($this->_gameResults->themes as $theme) {
- $genres[] = $theme['name'];
+ if (!empty($game->genres)) {
+ foreach ($game->genres as $genre) {
+ $genres[] = $genre['name'] ?? '';
+ }
+ }
+ // Fall back to themes if no genres
+ if (empty($genres) && !empty($game->themes)) {
+ foreach ($game->themes as $theme) {
+ $genres[] = $theme['name'] ?? '';
+ }
+ }
+ $genreName = $this->_matchGenre(implode(',', array_filter($genres)));
+
+ // Get best cover image URL (prefer higher quality)
+ $coverUrl = $this->getIGDBImageUrl($game->cover ?? null, 'cover_big');
+
+ // Get best backdrop/screenshot
+ $backdropUrl = '';
+ if (!empty($game->artworks)) {
+ $backdropUrl = $this->getIGDBImageUrl($game->artworks[0] ?? null, '1080p');
+ } elseif (!empty($game->screenshots)) {
+ $backdropUrl = $this->getIGDBImageUrl($game->screenshots[0] ?? null, '1080p');
+ }
+
+ // Get trailer URL if available
+ $trailerUrl = '';
+ if (!empty($game->videos)) {
+ foreach ($game->videos as $video) {
+ if (isset($video['video_id'])) {
+ $trailerUrl = 'https://www.youtube.com/watch?v=' . $video['video_id'];
+ break;
+ }
}
}
- $genreName = $this->_matchGenre(implode(',', $genres));
+ // Get rating (ESRB or PEGI)
+ $esrb = $this->getIGDBAgeRating($game->age_ratings ?? []);
- $releaseDate = now()->format('Y-m-d');
- if (isset($this->_gameResults->first_release_date) && strtotime($this->_gameResults->first_release_date) !== false) {
- $releaseDate = $this->_gameResults->first_release_date->format('Y-m-d');
+ // Get release date for PC
+ $releaseDate = $this->getIGDBReleaseDate($game);
+
+ // Get game URL
+ $gameUrl = $game->url ?? ('https://www.igdb.com/games/' . ($game->slug ?? $game->id));
+
+ // Build summary/review text
+ $review = $game->summary ?? '';
+ if (empty($review) && isset($game->storyline)) {
+ $review = $game->storyline;
}
+ // Add additional info to review
+ $additionalInfo = [];
+ if (!empty($developers)) {
+ $additionalInfo[] = 'Developer: ' . implode(', ', array_slice($developers, 0, 3));
+ }
+ if (!empty($game->game_modes)) {
+ $modes = array_map(fn($m) => $m['name'] ?? '', $game->game_modes);
+ $additionalInfo[] = 'Modes: ' . implode(', ', array_filter($modes));
+ }
+ if (!empty($additionalInfo) && !empty($review)) {
+ $review .= "\n\n" . implode("\n", $additionalInfo);
+ }
+
+ Log::info('Games: IGDB data retrieved', [
+ 'title' => $game->name,
+ 'id' => $game->id,
+ 'has_cover' => !empty($coverUrl),
+ 'has_backdrop' => !empty($backdropUrl),
+ 'genres' => $genres,
+ ]);
+
return [
- 'title' => $this->_gameResults->name,
- 'asin' => $this->_gameResults->id,
- 'review' => $this->_gameResults->summary ?? '',
- 'coverurl' => isset($this->_gameResults->cover) ? 'https:' . $this->_gameResults->cover['url'] : '',
+ 'title' => $game->name,
+ 'asin' => 'igdb-' . $game->id, // Prefix to distinguish from Steam IDs
+ 'review' => $review,
+ 'coverurl' => $coverUrl,
'releasedate' => $releaseDate,
- 'esrb' => isset($this->_gameResults->aggregated_rating) ? round($this->_gameResults->aggregated_rating) . '%' : 'Not Rated',
- 'url' => $this->_gameResults->url ?? '',
- 'backdropurl' => isset($this->_gameResults->screenshots) ? 'https:' . str_replace('t_thumb', 't_cover_big', $this->_gameResults->screenshots[0]['url']) : '',
- 'publisher' => !empty($publishers) ? implode(',', $publishers) : 'Unknown',
+ '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)) : '',
];
}
+ /**
+ * Get properly formatted IGDB image URL.
+ *
+ * @param array|null $imageData
+ * @param string $size Size: thumb, cover_small, cover_big, 720p, 1080p
+ * @return string
+ */
+ protected function getIGDBImageUrl(?array $imageData, string $size = 'cover_big'): string
+ {
+ if (empty($imageData)) {
+ return '';
+ }
+
+ // If we have image_id, construct the URL properly
+ if (isset($imageData['image_id'])) {
+ return 'https://images.igdb.com/igdb/image/upload/t_' . $size . '/' . $imageData['image_id'] . '.jpg';
+ }
+
+ // Fall back to URL manipulation if we have a URL
+ if (isset($imageData['url'])) {
+ $url = $imageData['url'];
+ // Ensure https
+ if (strpos($url, '//') === 0) {
+ $url = 'https:' . $url;
+ }
+ // Replace size in URL
+ return preg_replace('/t_[a-z0-9_]+/', 't_' . $size, $url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get age rating string from IGDB age ratings.
+ */
+ protected function getIGDBAgeRating(array $ageRatings): string
+ {
+ if (empty($ageRatings)) {
+ return 'Not Rated';
+ }
+
+ // IGDB age rating categories: 1 = ESRB, 2 = PEGI
+ // ESRB ratings: 6=RP, 7=EC, 8=E, 9=E10, 10=T, 11=M, 12=AO
+ $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: 1=3, 2=7, 3=12, 4=16, 5=18
+ $pegiMap = [
+ 1 => 'PEGI 3',
+ 2 => 'PEGI 7',
+ 3 => 'PEGI 12',
+ 4 => 'PEGI 16',
+ 5 => 'PEGI 18',
+ ];
+
+ foreach ($ageRatings as $rating) {
+ $category = $rating['category'] ?? 0;
+ $ratingValue = $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 getIGDBReleaseDate(Game $game): string
+ {
+ // Try to get PC-specific release date
+ if (!empty($game->release_dates)) {
+ foreach ($game->release_dates as $release) {
+ // Platform 6 = PC (Windows)
+ if (isset($release['platform']) && $release['platform'] === 6 && isset($release['date'])) {
+ return Carbon::createFromTimestamp($release['date'])->format('Y-m-d');
+ }
+ }
+ // Fall back to first release date
+ if (isset($game->release_dates[0]['date'])) {
+ return Carbon::createFromTimestamp($game->release_dates[0]['date'])->format('Y-m-d');
+ }
+ }
+
+ // Fall back to first_release_date
+ 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');
+ }
+
/**
* Retry an operation with exponential backoff.
*
diff --git a/config/igdb.php b/config/igdb.php
index 2138917de..29e27f972 100644
--- a/config/igdb.php
+++ b/config/igdb.php
@@ -14,8 +14,10 @@ return [
* Here you can set how long each query should be cached (in seconds).
*
* To turn cache off set this value to 0
+ *
+ * Recommended: 86400 (24 hours) for game data that rarely changes
*/
- 'cache_lifetime' => env((int) 'IGDB_CACHE_LIFETIME', 3600),
+ 'cache_lifetime' => env('IGDB_CACHE_LIFETIME', 86400),
/*
* Path where the webhooks should be handled.