From f289938f81a90b203c4d036f35d64e7d4d221f16 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 26 Dec 2025 16:03:22 +0100 Subject: [PATCH] Create Games service --- Blacklight/Games.php | 1607 ----------------- .../Controllers/Admin/AdminGameController.php | 6 +- app/Http/Controllers/DetailsController.php | 4 +- app/Http/Controllers/GamesController.php | 4 +- app/Models/GamesInfo.php | 57 + app/Services/GamesProcessor.php | 8 +- app/Services/GamesService.php | 797 ++++++++ app/Services/GamesTitleParser.php | 243 +++ app/Services/IGDBService.php | 693 +++++++ misc/testing/PostProc/getGameCovers.php | 6 +- 10 files changed, 1805 insertions(+), 1620 deletions(-) delete mode 100755 Blacklight/Games.php create mode 100644 app/Services/GamesService.php create mode 100644 app/Services/GamesTitleParser.php create mode 100644 app/Services/IGDBService.php diff --git a/Blacklight/Games.php b/Blacklight/Games.php deleted file mode 100755 index c373881af..000000000 --- a/Blacklight/Games.php +++ /dev/null @@ -1,1607 +0,0 @@ -[\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'; - - public bool $echoOutput; - - public string|int|null $gameQty; - - public string $imgSavePath; - - public int $matchPercentage; - - public bool $maxHitRequest; - - /** - * @var null|string - */ - public mixed $publicKey; - - public string $renamed; - - protected string $_classUsed; - - protected string $_gameID; - - protected mixed $_gameResults; - - protected SteamService $_getGame; - - protected int $_resultsFound = 0; - - public string $catWhere; - - protected $igdbSleep; - - protected ColorCLI $colorCli; - - // Processing stats - protected int $processedCount = 0; - protected int $matchedCount = 0; - protected int $failedCount = 0; - protected int $cachedCount = 0; - - /** - * Games constructor. - * - * - * @throws \Exception - */ - public function __construct() - { - $this->echoOutput = config('nntmux.echocli'); - $this->colorCli = new ColorCLI; - $this->_getGame = new SteamService; - $this->gameQty = Settings::settingValue('maxgamesprocessed') !== '' ? (int) Settings::settingValue('maxgamesprocessed') : 150; - $this->imgSavePath = config('nntmux_settings.covers_path').'/games/'; - $this->renamed = (int) Settings::settingValue('lookupgames') === 2 ? 'AND isrenamed = 1' : ''; - $this->matchPercentage = 60; - $this->maxHitRequest = false; - $this->catWhere = 'AND categories_id = '.Category::PC_GAMES.' '; - } - - /** - * @return Model|null|static - */ - public function getGamesInfoById($id) - { - return GamesInfo::query() - ->where('gamesinfo.id', $id) - ->leftJoin('genres as g', 'g.id', '=', 'gamesinfo.genres_id') - ->select(['gamesinfo.*', 'g.title as genres']) - ->first(); - } - - public function getGamesInfoByName(string $title) - { - $bestMatch = false; - - if (empty($title)) { - return false; - } - - $results = GamesInfo::search($title)->get(); - - if ($results instanceof \Traversable) { - $bestMatchPct = 0; - $normQuery = $this->normalizeForMatch($title); - foreach ($results as $result) { - $candidate = is_array($result) ? ($result['title'] ?? '') : ($result->title ?? ''); - if ($candidate === '') { - continue; - } - // Exact match fast-path. - if ($candidate === $title) { - $bestMatch = $result; - break; - } - $score = $this->computeSimilarity($normQuery, $this->normalizeForMatch($candidate)); - if ($score >= self::GAME_MATCH_PERCENTAGE && $score > $bestMatchPct) { - $bestMatch = $result; - $bestMatchPct = $score; - } - } - } - - return $bestMatch; - } - - /** - * @throws \InvalidArgumentException - */ - public function getRange(): LengthAwarePaginator - { - return GamesInfo::query() - ->select(['gi.*', 'g.title as genretitle']) - ->from('gamesinfo as gi') - ->join('genres as g', 'gi.genres_id', '=', 'g.id') - ->orderByDesc('created_at') - ->paginate(config('nntmux.items_per_page')); - } - - public function getCount(): int - { - $res = GamesInfo::query()->count(['id']); - - return $res ?? 0; - } - - /** - * @throws \Exception - */ - public function getGamesRange($page, $cat, $start, $num, array|string $orderBy = '', string $maxAge = '', array $excludedCats = []): array - { - - $page = max(1, $page); - $start = max(0, $start); - - $browseBy = $this->getBrowseBy(); - $catsrch = ''; - if (\count($cat) > 0 && $cat[0] !== -1) { - $catsrch = Category::getCategorySearch($cat); - } - if ($maxAge > 0) { - $maxAge = sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge); - } - $exccatlist = ''; - if (\count($excludedCats) > 0) { - $exccatlist = ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')'; - } - $order = $this->getGamesOrder($orderBy); - $gamesSql = - "SELECT SQL_CALC_FOUND_ROWS gi.id, GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id FROM gamesinfo gi LEFT JOIN releases r ON gi.id = r.gamesinfo_id WHERE gi.title != '' AND gi.cover = 1 AND r.passwordstatus " - .app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(). - $browseBy. - $catsrch. - $maxAge. - $exccatlist. - ' GROUP BY gi.id ORDER BY '.($order[0]).' '.($order[1]). - ($start === false ? '' : ' LIMIT '.$num.' OFFSET '.$start); - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - $gamesCache = Cache::get(md5($gamesSql.$page)); - if ($gamesCache !== null) { - $games = $gamesCache; - } else { - $data = DB::select($gamesSql); - $games = ['total' => DB::select('SELECT FOUND_ROWS() AS total'), 'result' => $data]; - Cache::put(md5($gamesSql.$page), $games, $expiresAt); - } - $gameIDs = $releaseIDs = false; - if (\is_array($games['result'])) { - foreach ($games['result'] as $game => $id) { - $gameIDs = [$id->id]; - $releaseIDs = [$id->grp_release_id]; - } - } - $returnSql = - 'SELECT r.id, r.rarinnerfilecount, r.grabs, r.comments, r.totalpart, r.size, r.postdate, r.searchname, r.haspreview, r.passwordstatus, r.guid, g.name AS group_name, df.failed AS failed, gi.*, YEAR (gi.releasedate) as year, r.gamesinfo_id, rn.releases_id AS nfoid FROM releases r LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id INNER JOIN gamesinfo gi ON gi.id = r.gamesinfo_id WHERE gi.id IN ('.(\is_array($gameIDs) ? implode(',', $gameIDs) : -1).') AND r.id IN ('.(\is_array($releaseIDs) ? implode(',', $releaseIDs) : -1).')'.$catsrch.' GROUP BY gi.id ORDER BY '.($order[0]).' '.($order[1]); - $return = Cache::get(md5($returnSql.$page)); - if ($return !== null) { - return $return; - } - $return = DB::select($returnSql); - if (\count($return) > 0) { - $return[0]->_totalcount = $games['total'][0]->total ?? 0; - } - Cache::put(md5($returnSql.$page), $return, $expiresAt); - - return $return; - } - - public function getGamesOrder(array|string $orderBy): array - { - $order = $orderBy === '' ? 'r.postdate' : $orderBy; - $orderArr = explode('_', $order); - $orderField = match ($orderArr[0]) { - 'title' => 'gi.title', - 'releasedate' => 'gi.releasedate', - 'genre' => 'gi.genres_id', - 'size' => 'r.size', - 'files' => 'r.totalpart', - 'stats' => 'r.grabs', - default => 'r.postdate', - }; - $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc'; - - return [$orderField, $orderSort]; - } - - /** - * @return string[] - */ - public function getGamesOrdering(): array - { - return [ - 'title_asc', 'title_desc', 'posted_asc', 'posted_desc', 'size_asc', 'size_desc', - 'files_asc', 'files_desc', 'stats_asc', 'stats_desc', - 'releasedate_asc', 'releasedate_desc', 'genre_asc', 'genre_desc', - ]; - } - - /** - * @return string[] - */ - public function getBrowseByOptions(): array - { - return ['title' => 'title', 'genre' => 'genres_id', 'year' => 'year']; - } - - public function getBrowseBy(): string - { - $browseBy = ' '; - foreach ($this->getBrowseByOptions() as $bbk => $bbv) { - if (! empty($_REQUEST[$bbk])) { - $bbs = stripslashes($_REQUEST[$bbk]); - if ($bbk === 'year') { - $browseBy .= ' AND YEAR (gi.releasedate) '.'LIKE '.escapeString('%'.$bbs.'%'); - } else { - $browseBy .= ' AND gi.'.$bbv.' '.'LIKE '.escapeString('%'.$bbs.'%'); - } - } - } - - return $browseBy; - } - - public function update($id, $title, $asin, $url, $publisher, $releaseDate, $esrb, $cover, $trailerUrl, $genreID): void - { - GamesInfo::query() - ->where('id', $id) - ->update( - [ - 'title' => $title, - 'asin' => $asin, - 'url' => $url, - 'publisher' => $publisher, - 'releasedate' => $releaseDate, - 'esrb' => $esrb, - 'cover' => $cover, - 'trailer' => $trailerUrl, - 'genres_id' => $genreID, - ] - ); - } - - /** - * @throws ModelException - * @throws SdkException - * @throws \JsonException - * @throws InvalidParamsException - * @throws MissingEndpointException - * @throws \ReflectionException - */ - public function updateGamesInfo($gameInfo): bool - { - $gen = new Genres(['Settings' => null]); - $ri = new \App\Services\ReleaseImageService; - - $game = []; - $titleKey = $this->generateCacheKey($gameInfo['title']); - - // Check if we've already failed to find this game recently - if (Cache::has("game_lookup_failed:{$titleKey}")) { - Log::debug('Games: Skipping previously failed lookup', ['title' => $gameInfo['title']]); - $this->cachedCount++; - return false; - } - - // Check cache first for existing lookup - $cachedResult = Cache::get("game_lookup:{$titleKey}"); - if ($cachedResult !== null) { - Log::debug('Games: Using cached lookup result', ['title' => $gameInfo['title']]); - $this->cachedCount++; - return $this->saveGameInfo($cachedResult, $gen, $ri, $gameInfo); - } - - // Process Steam first as Steam has more details - $this->_gameResults = false; - $genreName = ''; - $this->_classUsed = 'Steam'; - - $steamGameID = $this->fetchFromSteam($gameInfo['title']); - - if ($steamGameID !== false) { - $this->_gameResults = $this->_getGame->getAll($steamGameID); - - if ($this->_gameResults !== false) { - if (empty($this->_gameResults['title'])) { - return false; - } - if (! empty($this->_gameResults['cover'])) { - $game['coverurl'] = (string) $this->_gameResults['cover']; - } - - if (! empty($this->_gameResults['backdrop'])) { - $game['backdropurl'] = (string) $this->_gameResults['backdrop']; - } - - $game['title'] = (string) $this->_gameResults['title']; - $game['asin'] = $this->_gameResults['steamid']; - $game['url'] = (string) $this->_gameResults['directurl']; - - if (! empty($this->_gameResults['publisher'])) { - $game['publisher'] = (string) $this->_gameResults['publisher']; - } else { - $game['publisher'] = 'Unknown'; - } - - if (! empty($this->_gameResults['rating'])) { - $game['esrb'] = (string) $this->_gameResults['rating']; - } else { - $game['esrb'] = 'Not Rated'; - } - - if (! empty($this->_gameResults['releasedate'])) { - $dateReleased = strtotime($this->_gameResults['releasedate']) === false ? '' : $this->_gameResults['releasedate']; - $game['releasedate'] = ($this->_gameResults['releasedate'] === '' || strtotime($this->_gameResults['releasedate']) === false) ? null : Carbon::createFromFormat('M j, Y', Carbon::parse($dateReleased)->toFormattedDateString())->format('Y-m-d'); - } - - if (! empty($this->_gameResults['description'])) { - $game['review'] = (string) $this->_gameResults['description']; - } - - if (! empty($this->_gameResults['genres'])) { - $genres = $this->_gameResults['genres']; - $genreName = $this->_matchGenre($genres); - } - } - } - - if (config('config.credentials.client_id') !== '' && config('config.credentials.client_secret') !== '') { - try { - if ($steamGameID === false || $this->_gameResults === false) { - $game = $this->fetchFromIGDB($gameInfo['title'], $genreName); - if ($game === false) { - // Cache the failed lookup to avoid repeated API calls - Cache::put("game_lookup_failed:{$titleKey}", true, self::FAILED_LOOKUP_CACHE_TTL); - return false; - } - } - } catch (ClientException $e) { - if ($e->getCode() === 429) { - $this->igdbSleep = now()->endOfMonth(); - Log::warning('Games: IGDB rate limit exceeded, sleeping until end of month'); - } - } - } - - // Load genres. - $defaultGenres = $gen->loadGenres(Genres::GAME_TYPE); - - // Prepare database values. - if (isset($game['coverurl'])) { - $game['cover'] = 1; - } else { - $game['cover'] = 0; - } - if (isset($game['backdropurl'])) { - $game['backdrop'] = 1; - } else { - $game['backdrop'] = 0; - } - if (! isset($game['trailer'])) { - $game['trailer'] = 0; - } - if (empty($game['title'])) { - $game['title'] = $gameInfo['title']; - } - if (! isset($game['releasedate'])) { - $game['releasedate'] = ''; - } - - if ($game['releasedate'] === '') { - $game['releasedate'] = ''; - } - if (! isset($game['review'])) { - $game['review'] = 'No Review'; - } - $game['classused'] = $this->_classUsed; - - if (empty($genreName)) { - $genreName = 'Unknown'; - } - - if (\in_array(strtolower($genreName), $defaultGenres, false)) { - $genreKey = array_search(strtolower($genreName), $defaultGenres, false); - } else { - $genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => Genres::GAME_TYPE]); - } - - $game['gamesgenre'] = $genreName; - $game['gamesgenreID'] = $genreKey; - - if (! empty($game['asin'])) { - try { - DB::beginTransaction(); - - $check = GamesInfo::query()->where('asin', $game['asin'])->first(); - if ($check === null) { - $gamesId = GamesInfo::query() - ->insertGetId( - [ - 'title' => $game['title'], - 'asin' => $game['asin'], - 'url' => $game['url'], - 'publisher' => $game['publisher'], - 'genres_id' => $game['gamesgenreID'] === -1 ? 'null' : $game['gamesgenreID'], - 'esrb' => $game['esrb'], - 'releasedate' => $game['releasedate'] !== '' ? $game['releasedate'] : 'null', - 'review' => substr($game['review'], 0, 3000), - 'cover' => $game['cover'], - 'backdrop' => $game['backdrop'], - 'trailer' => $game['trailer'], - 'classused' => $game['classused'], - 'created_at' => now(), - 'updated_at' => now(), - ] - ); - - Log::info('Games: Added new game', ['id' => $gamesId, 'title' => $game['title'], 'source' => $this->_classUsed]); - } else { - $gamesId = $check['id']; - GamesInfo::query() - ->where('id', $gamesId) - ->update( - [ - 'title' => $game['title'], - 'asin' => $game['asin'], - 'url' => $game['url'], - 'publisher' => $game['publisher'], - 'genres_id' => $game['gamesgenreID'] === -1 ? 'null' : $game['gamesgenreID'], - 'esrb' => $game['esrb'], - 'releasedate' => $game['releasedate'] !== '' ? $game['releasedate'] : 'null', - 'review' => substr($game['review'], 0, 3000), - 'cover' => $game['cover'], - 'backdrop' => $game['backdrop'], - 'trailer' => $game['trailer'], - 'classused' => $game['classused'], - ] - ); - - Log::debug('Games: Updated existing game', ['id' => $gamesId, 'title' => $game['title']]); - } - - DB::commit(); - - // Cache the successful lookup - Cache::put("game_lookup:{$titleKey}", $game, self::GAME_CACHE_TTL); - $this->matchedCount++; - - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Games: Database error saving game', [ - 'title' => $game['title'], - 'error' => $e->getMessage() - ]); - return false; - } - } - - if (! empty($gamesId)) { - if ($this->echoOutput) { - $this->colorCli->header('Added/updated game: '). - $this->colorCli->alternateOver(' Title: '). - $this->colorCli->primary($game['title']). - $this->colorCli->alternateOver(' Source: '). - $this->colorCli->primary($this->_classUsed); - } - if ($game['cover'] === 1) { - $game['cover'] = $ri->saveImage($gamesId, $game['coverurl'], $this->imgSavePath, 250, 250); - } - if ($game['backdrop'] === 1) { - $game['backdrop'] = $ri->saveImage($gamesId.'-backdrop', $game['backdropurl'], $this->imgSavePath, 1920, 1024); - } - } elseif ($this->echoOutput) { - $this->colorCli->headerOver('Nothing to update: '). - $this->colorCli->primary($game['title'].' (PC)'); - } - - return ! empty($gamesId) ? $gamesId : false; - } - - /** - * @throws \InvalidArgumentException - * @throws \RuntimeException - * @throws \Exception - */ - public function processGamesReleases(): void - { - // Reset stats - $this->processedCount = 0; - $this->matchedCount = 0; - $this->failedCount = 0; - $this->cachedCount = 0; - - $startTime = microtime(true); - - $query = Release::query() - ->where('gamesinfo_id', '=', 0) - ->where('categories_id', '=', Category::PC_GAMES); - if ((int) Settings::settingValue('lookupgames') === 2) { - $query->where('isrenamed', '=', 1); - } - $query->select(['searchname', 'id']) - ->orderByDesc('postdate') - ->limit($this->gameQty); - - $res = $query->get(); - - if ($res->count() > 0) { - if ($this->echoOutput) { - $this->colorCli->header('Processing '.$res->count().' games release(s).'); - } - - Log::info('Games: Starting processing', ['count' => $res->count()]); - - // Collect batch updates for releases - $releaseUpdates = []; - - foreach ($res as $arr) { - $this->processedCount++; - - // Reset maxhitrequest - $this->maxHitRequest = false; - - $gameInfo = $this->parseTitle($arr['searchname']); - if ($gameInfo !== false) { - if ($this->echoOutput) { - $this->colorCli->info('Looking up: '.$gameInfo['title'].' (PC)'); - } - // Check for existing games entry. - $gameCheck = $this->getGamesInfoByName($gameInfo['title']); - - if ($gameCheck === false) { - $gameId = $this->updateGamesInfo($gameInfo); - if ($gameId === false) { - $gameId = -2; - $this->failedCount++; - - // Leave gamesinfo_id 0 to parse again - if ($this->maxHitRequest === true) { - $gameId = 0; - } - } - } else { - $gameId = $gameCheck['id']; - $this->cachedCount++; - } - - // Collect for batch update - $releaseUpdates[$arr['id']] = $gameId; - } else { - // Could not parse release title. - $releaseUpdates[$arr['id']] = -2; - $this->failedCount++; - - if ($this->echoOutput) { - echo '.'; - } - } - } - - // Batch update releases - $this->batchUpdateReleases($releaseUpdates); - - $elapsed = round(microtime(true) - $startTime, 2); - - Log::info('Games: Processing completed', [ - 'processed' => $this->processedCount, - 'matched' => $this->matchedCount, - 'cached' => $this->cachedCount, - 'failed' => $this->failedCount, - 'elapsed_seconds' => $elapsed, - ]); - - if ($this->echoOutput) { - $this->colorCli->header(sprintf( - 'Games processing complete: %d processed, %d matched, %d cached, %d failed (%.2fs)', - $this->processedCount, - $this->matchedCount, - $this->cachedCount, - $this->failedCount, - $elapsed - )); - } - } elseif ($this->echoOutput) { - $this->colorCli->header('No games releases to process.'); - } - } - - /** - * Parse the game release title. - * - * @return array|false - */ - public function parseTitle(string $releaseName): bool|array - { - // Normalize separators and strip common surrounding tags first. - $name = (string) $releaseName; - - // Remove simple file extensions at the end. - $name = (string) preg_replace('/\.(zip|rar|7z|iso|nfo|sfv|exe|mkv|mp4|avi)$/i', '', $name); - $name = str_replace('%20', ' ', $name); - - // Remove leading bracketed tag like [GROUP] or [PC] - $name = (string) preg_replace('/^\[[^]]+\]\s*/', '', $name); - - // Remove "PC ISO) (" artifact - $name = (string) preg_replace('/^(PC\s*ISO\)\s*\()/i', '', $name); - - // Remove common bracketed/parenthesized tags (languages, qualities, platforms, versions) - $name = (string) preg_replace('/\[[^]]{1,80}\]|\([^)]{1,80}\)/', ' ', $name); - - // Remove edition and extra tags - $name = (string) preg_replace( - '/\b(Game\s+of\s+the\s+Year|GOTY|Definitive Edition|Deluxe Edition|Ultimate Edition|Complete Edition|Remastered|HD Remaster|Directors? Cut|Anniversary Edition)\b/i', - ' ', - $name - ); - - // Remove update/patch followed by version numbers like 1.2.3 - $name = (string) preg_replace('/\b(Update|Patch|Hotfix)\b[\s._-]*v?\d+(?:\.\d+){1,}\b/i', ' ', $name); - - // Remove MULTI tokens - $name = (string) preg_replace('/\bMULTI\d+|MULTi\d+\b/i', ' ', $name); - - // Remove general dotted version tokens anywhere: v1.0.1436.28 or 1.7.29 - $name = (string) preg_replace('/(?:^|[\s._-])v\d+(?:[\s._-]\d+){1,}(?=$|[\s._-])/i', ' ', $name); - // Remove multi-part numeric sequences (>=3 parts) like 1.7.29 or 1_2_3 - $name = (string) preg_replace('/(?:^|[\s._-])\d+(?:[\s._-]\d+){2,}(?=$|[\s._-])/', ' ', $name); - - // Remove other common release tags - $name = (string) preg_replace('/\b(Incl(?:uding)?\s+DLCs?|DLCs?|PROPER|REPACK|RIP|ISO|CRACK(?:FIX)?|BETA|ALPHA)\b/i', ' ', $name); - // Remove standalone update/patch/hotfix noise - $name = (string) preg_replace('/\b(Update|Patch|Hotfix)\b/i', ' ', $name); - - // Remove scene group suffix like "- CODEX" or "- EMPRESS" - $groupAlternation = implode('|', array_map('preg_quote', self::SCENE_GROUPS)); - $name = (string) preg_replace('/\s*-\s*(?:'.$groupAlternation.'|[A-Z0-9]{2,})\s*$/i', '', $name); - - // Replace separators with spaces. - $name = (string) preg_replace('/[._+]+/', ' ', $name); - - // Second pass: remove edition tokens now that separators are normalized - $name = (string) preg_replace( - '/\b(Game\s+of\s+the\s+Year|GOTY|Definitive Edition|Deluxe Edition|Ultimate Edition|Complete Edition|Remastered|HD Remaster|Directors? Cut|Anniversary Edition)\b/i', - ' ', - $name - ); - // Remove leftover standalone tokens - $name = (string) preg_replace('/\b(Incl|Including|DLC|DLCs)\b/i', ' ', $name); - - // Token-based cleanup for version sequences while preserving single numbers - $tokens = preg_split('/\s+/', trim($name)) ?: []; - $filtered = []; - $i = 0; - $tcount = \count($tokens); - while ($i < $tcount) { - $tok = $tokens[$i]; - if (preg_match('/^v?\d+$/i', $tok)) { - $j = $i + 1; - $numRun = 0; - while ($j < $tcount && preg_match('/^\d+$/', $tokens[$j])) { - $numRun++; - $j++; - } - $startsWithV = preg_match('/^v\d+$/i', $tok) === 1; - if (($startsWithV && $numRun >= 1) || (! $startsWithV && $numRun >= 2)) { - // Skip this version run - $i = $j; - - continue; - } - // Not a version run: keep the single number token - $filtered[] = $tok; - $i++; - - continue; - } - $filtered[] = $tok; - $i++; - } - $name = implode(' ', $filtered); - - // Remove spaced-out version sequences like `v1 0 1436 28` or `1 7 29` - $name = (string) preg_replace('/(?:^|\s)v\d+(?:\s+\d+){1,}(?=$|\s)/i', ' ', $name); - $name = (string) preg_replace('/(?:^|\s)\d+(?:\s+\d+){2,}(?=$|\s)/', ' ', $name); - - // Collapse multiple spaces and trim hyphens used as separators - $name = (string) preg_replace('/\s{2,}/', ' ', $name); - $name = trim($name, " \t\n\r\0\x0B-_"); - - // Special fix carried over from previous implementation. - $name = str_replace(' RF ', ' ', $name); - - // Final aggressive cleanup: strip lingering multi-part version sequences if any remain - for ($i = 0; $i < 2; $i++) { - $name = (string) preg_replace('/(?:^|\s)v\d+(?:\s+\d+){1,}(?=$|\s)/i', ' ', $name); - $name = (string) preg_replace('/(?:^|\s)\d+(?:\s+\d+){2,}(?=$|\s)/', ' ', $name); - $name = (string) preg_replace('/\b[vV]\d+(?:[ ._-]\d+){1,}\b/', ' ', $name); - $name = (string) preg_replace('/\b\d+(?:[ ._-]\d+){2,}\b/', ' ', $name); - $name = (string) preg_replace('/\s{2,}/', ' ', $name); - $name = trim($name, " \t\n\r\0\x0B-_"); - } - - // Final cleanup: if empty, fallback to legacy regex - if ($name === '') { - if (preg_match(self::GAMES_TITLE_PARSE_REGEX, preg_replace('/\sMulti\d?\s/i', '', $releaseName), $hits)) { - $result = []; - $result['title'] = str_replace(' RF ', ' ', preg_replace('/(?:[-:._]|%20|[\[\]])/', ' ', $hits['title'])); - $result['title'] = preg_replace('/(brazilian|chinese|croatian|danish|deutsch|dutch|english|estonian|flemish|finnish|french|german|greek|hebrew|icelandic|italian|latin|nordic|norwegian|polish|portuguese|japenese|japanese|russian|serbian|slovenian|spanish|spanisch|swedish|thai|turkish)$/i', '', $result['title']); - $result['title'] = preg_replace('/^(PC\sISO\)\s\()/i', '', $result['title']); - $result['title'] = trim(preg_replace('/\s{2,}/', ' ', $result['title'])); - if (empty($result['title'])) { - return false; - } - $result['release'] = $releaseName; - - return array_map('trim', $result); - } - - return false; - } - - return [ - 'title' => $name, - 'release' => $releaseName, - ]; - } - - /** - * Generate a consistent cache key from a game title. - */ - protected function generateCacheKey(string $title): string - { - return md5(mb_strtolower(trim($title))); - } - - /** - * Fetch game ID from Steam with rate limiting. - * - * @return int|false - */ - protected function fetchFromSteam(string $title): int|false - { - return RateLimiter::attempt( - self::STEAM_RATE_LIMIT_KEY, - self::STEAM_REQUESTS_PER_MINUTE, - function () use ($title) { - return $this->_getGame->search($title); - }, - 60 // decay in seconds - ) ?: $this->retryWithBackoff(function () use ($title) { - return $this->_getGame->search($title); - }, self::MAX_RETRIES, 'Steam'); - } - - /** - * Fetch game from IGDB with rate limiting and improved search. - * Uses multiple search strategies for better matching. - * - * @return array|false - */ - protected function fetchFromIGDB(string $title, string &$genreName): array|false - { - $this->_classUsed = 'IGDB'; - - // Try multiple search strategies - $game = $this->searchIGDBWithStrategies($title); - - if ($game === null) { - $this->colorCli->notice('IGDB found no valid results for: ' . $title); - Log::debug('Games: IGDB search failed', ['title' => $title]); - return false; - } - - return $this->buildGameDataFromIGDB($game, $genreName); - } - - /** - * 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; - } - } - - // 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; - } - } - - 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 - ); - - // RateLimiter::attempt returns true if rate limited, so check for actual Game instance - return $result instanceof Game ? $result : null; - } 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 - ); - - // RateLimiter::attempt returns true if rate limited - if ($results === true || 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 = []; - $developers = []; - if (!empty($game->involved_companies)) { - $involvedCompanies = $game->involved_companies; - // Convert Collection to array if needed - if ($involvedCompanies instanceof \Illuminate\Support\Collection) { - $involvedCompanies = $involvedCompanies->toArray(); - } - foreach ($involvedCompanies as $company) { - // Handle both array and object access - $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 (prefer genres over themes) - $genres = []; - if (!empty($game->genres)) { - $gameGenres = $game->genres; - if ($gameGenres instanceof \Illuminate\Support\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 \Illuminate\Support\Collection) { - $gameThemes = $gameThemes->toArray(); - } - foreach ($gameThemes as $theme) { - $genres[] = is_array($theme) ? ($theme['name'] ?? '') : ($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)) { - $artworks = $game->artworks; - $firstArtwork = ($artworks instanceof \Illuminate\Support\Collection) ? $artworks->first() : ($artworks[0] ?? null); - $backdropUrl = $this->getIGDBImageUrl($firstArtwork, '1080p'); - } elseif (!empty($game->screenshots)) { - $screenshots = $game->screenshots; - $firstScreenshot = ($screenshots instanceof \Illuminate\Support\Collection) ? $screenshots->first() : ($screenshots[0] ?? null); - $backdropUrl = $this->getIGDBImageUrl($firstScreenshot, '1080p'); - } - - // Get trailer URL if available - $trailerUrl = ''; - if (!empty($game->videos)) { - $videos = $game->videos; - if ($videos instanceof \Illuminate\Support\Collection) { - $videos = $videos->toArray(); - } - foreach ($videos as $video) { - $videoId = is_array($video) ? ($video['video_id'] ?? null) : ($video->video_id ?? null); - if ($videoId) { - $trailerUrl = 'https://www.youtube.com/watch?v=' . $videoId; - break; - } - } - } - - // Get rating (ESRB or PEGI) - $ageRatings = $game->age_ratings ?? []; - if ($ageRatings instanceof \Illuminate\Support\Collection) { - $ageRatings = $ageRatings->toArray(); - } - $esrb = $this->getIGDBAgeRating($ageRatings); - - // 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)) { - $gameModes = $game->game_modes; - // Handle both Collection and array types - if ($gameModes instanceof \Illuminate\Support\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); - } - - Log::info('Games: IGDB data retrieved', [ - 'title' => $game->name, - 'id' => $game->id, - 'has_cover' => !empty($coverUrl), - 'has_backdrop' => !empty($backdropUrl), - 'genres' => $genres, - ]); - - return [ - 'title' => $game->name, - 'asin' => 'igdb-' . $game->id, // Prefix to distinguish from Steam IDs - '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)) : '', - ]; - } - - /** - * Get properly formatted IGDB image URL. - * - * @param array|object|null $imageData Can be an array or an IGDB model object (Cover, Screenshot, Artwork) - * @param string $size Size: thumb, cover_small, cover_big, 720p, 1080p - * @return string - */ - protected function getIGDBImageUrl(array|object|null $imageData, string $size = 'cover_big'): string - { - if (empty($imageData)) { - return ''; - } - - // Convert object to array if needed (IGDB Laravel package returns model objects) - if (is_object($imageData)) { - // Try to get properties from the object - $imageId = $imageData->image_id ?? ($imageData->imageId ?? null); - $url = $imageData->url ?? null; - $imageData = [ - 'image_id' => $imageId, - 'url' => $url, - ]; - } - - // If we have image_id, construct the URL properly - if (!empty($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 (!empty($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 = 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 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. - * - * @param callable $operation - * @param int $maxRetries - * @param string $source - * @return mixed - */ - protected function retryWithBackoff(callable $operation, int $maxRetries, string $source): mixed - { - $attempt = 0; - $lastException = null; - - while ($attempt < $maxRetries) { - try { - $result = $operation(); - if ($result !== false) { - return $result; - } - } catch (\Exception $e) { - $lastException = $e; - Log::warning("Games: {$source} request failed, retrying", [ - 'attempt' => $attempt + 1, - 'error' => $e->getMessage() - ]); - } - - $attempt++; - if ($attempt < $maxRetries) { - $delay = self::RETRY_DELAY_SECONDS * pow(2, $attempt - 1); // Exponential backoff - sleep($delay); - } - } - - if ($lastException) { - Log::error("Games: {$source} request failed after {$maxRetries} attempts", [ - 'error' => $lastException->getMessage() - ]); - } - - return false; - } - - /** - * Batch update release records with game IDs. - * - * @param array $updates Map of release_id => gamesinfo_id - */ - protected function batchUpdateReleases(array $updates): void - { - if (empty($updates)) { - return; - } - - try { - DB::beginTransaction(); - - foreach ($updates as $releaseId => $gameId) { - Release::query() - ->where('id', '=', $releaseId) - ->where('categories_id', '=', Category::PC_GAMES) - ->update(['gamesinfo_id' => $gameId]); - } - - DB::commit(); - - Log::debug('Games: Batch updated releases', ['count' => count($updates)]); - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Games: Failed to batch update releases', ['error' => $e->getMessage()]); - - // Fall back to individual updates on failure - foreach ($updates as $releaseId => $gameId) { - try { - Release::query() - ->where('id', '=', $releaseId) - ->where('categories_id', '=', Category::PC_GAMES) - ->update(['gamesinfo_id' => $gameId]); - } catch (\Exception $e) { - Log::error('Games: Failed to update release', [ - 'release_id' => $releaseId, - 'error' => $e->getMessage() - ]); - } - } - } - } - - /** - * Save game info from cached data. - * - * @param array $game Cached game data - * @param Genres $gen Genre handler - * @param \App\Services\ReleaseImageService $ri Image handler - * @param array $gameInfo Original game info - * @return bool|int - */ - protected function saveGameInfo(array $game, Genres $gen, \App\Services\ReleaseImageService $ri, array $gameInfo): bool|int - { - // Load genres. - $defaultGenres = $gen->loadGenres(Genres::GAME_TYPE); - - $genreName = $game['gamesgenre'] ?? 'Unknown'; - - if (in_array(strtolower($genreName), $defaultGenres, false)) { - $genreKey = array_search(strtolower($genreName), $defaultGenres, false); - } else { - $genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => Genres::GAME_TYPE]); - } - - $game['gamesgenreID'] = $genreKey; - - if (!empty($game['asin'])) { - $check = GamesInfo::query()->where('asin', $game['asin'])->first(); - if ($check !== null) { - return $check['id']; - } - } - - return false; - } - - /** - * See if genre name exists. - */ - public function matchGenreName($gameGenre): bool|string - { - $str = ''; - - // Game genres - expanded list including modern categories - switch ($gameGenre) { - case 'Action': - case 'Adventure': - case 'Arcade': - case 'Board Games': - case 'Cards': - case 'Casino': - case 'Flying': - case 'Puzzle': - case 'Racing': - case 'Rhythm': - case 'Role-Playing': - case 'RPG': - case 'Simulation': - case 'Sports': - case 'Strategy': - case 'Trivia': - // Additional modern genres - case 'Shooter': - case 'FPS': - case 'Horror': - case 'Survival': - case 'Sandbox': - case 'Open World': - case 'Platformer': - case 'Fighting': - case 'Stealth': - case 'MMO': - case 'MMORPG': - case 'Battle Royale': - case 'Roguelike': - case 'Roguelite': - case 'Metroidvania': - case 'Visual Novel': - case 'Point & Click': - case 'Management': - case 'City Builder': - case 'Tower Defense': - case 'Turn-Based': - case 'Real-Time': - case 'Educational': - case 'Music': - case 'Party': - case 'Indie': - case 'Hack and Slash': - case 'Souls-like': - case 'JRPG': - case 'ARPG': - case 'Tactical': - $str = $gameGenre; - break; - } - - return ($str !== '') ? $str : false; - } - - /** - * Get processing statistics from the last run. - * - * @return array{processed: int, matched: int, cached: int, failed: int} - */ - public function getProcessingStats(): array - { - return [ - 'processed' => $this->processedCount, - 'matched' => $this->matchedCount, - 'cached' => $this->cachedCount, - 'failed' => $this->failedCount, - ]; - } - - /** - * Clear game lookup caches. - * Useful when you want to re-process previously failed lookups. - */ - public function clearLookupCaches(): void - { - // Note: This requires Redis or a tag-aware cache driver to work efficiently - // For file-based cache, you may need to implement manual clearing - Log::info('Games: Lookup caches cleared'); - } - - protected function _matchGenre(string $genre = ''): string - { - $genreName = ''; - $a = str_replace('-', ' ', $genre); - $tmpGenre = explode(',', $a); - if (\is_array($tmpGenre)) { - foreach ($tmpGenre as $tg) { - $genreMatch = $this->matchGenreName(ucwords($tg)); - if ($genreMatch !== false) { - $genreName = (string) $genreMatch; - break; - } - } - if (empty($genreName)) { - $genreName = $tmpGenre[0]; - } - } else { - $genreName = $genre; - } - - return $genreName; - } - - // --- Helpers for improved matching --- - - private function normalizeForMatch(string $title): string - { - $t = mb_strtolower($title); - // strip scene groups at end - $groupAlternation = implode('|', array_map('preg_quote', self::SCENE_GROUPS)); - $t = (string) preg_replace('/\s*-\s*(?:'.$groupAlternation.'|[A-Z0-9]{2,})\s*$/i', '', $t); - // remove edition tokens and common noise - $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); - // remove languages/platform tokens - $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); - // remove punctuation - $t = (string) preg_replace('/[^a-z0-9]+/i', ' ', $t); - $t = trim(preg_replace('/\s{2,}/', ' ', $t)); - - return $t; - } - - private function computeSimilarity(string $a, string $b): float - { - if ($a === $b) { - return 100.0; - } - $percent = 0.0; - similar_text($a, $b, $percent); - // Try a Levenshtein-based score as well if possible - $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); - } -} diff --git a/app/Http/Controllers/Admin/AdminGameController.php b/app/Http/Controllers/Admin/AdminGameController.php index ff65fbbba..75d402151 100644 --- a/app/Http/Controllers/Admin/AdminGameController.php +++ b/app/Http/Controllers/Admin/AdminGameController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; -use Blacklight\Games; +use App\Services\GamesService; use Blacklight\Genres; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -15,7 +15,7 @@ class AdminGameController extends BasePageController */ public function index(Request $request) { - $game = new Games(['Settings' => null]); + $game = new GamesService; $meta_title = $title = 'Game List'; @@ -38,7 +38,7 @@ class AdminGameController extends BasePageController */ public function edit(Request $request) { - $games = new Games(['Settings' => null]); + $games = new GamesService; $gen = new Genres(['Settings' => null]); $meta_title = $title = 'Game Edit'; diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 524aeb622..e5850c50c 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -18,8 +18,8 @@ use App\Services\Releases\ReleaseSearchService; use App\Services\BookService; use App\Services\AnidbService; use App\Services\XxxBrowseService; +use App\Services\GamesService; use Blacklight\Console; -use Blacklight\Games; use Blacklight\Music; use App\Services\ReleaseExtraService; use Illuminate\Http\Request; @@ -118,7 +118,7 @@ class DetailsController extends BasePageController $game = ''; if ($data['gamesinfo_id'] !== '') { - $game = (new Games)->getGamesInfoById($data['gamesinfo_id']); + $game = (new GamesService)->getGamesInfoById($data['gamesinfo_id']); } $mus = ''; diff --git a/app/Http/Controllers/GamesController.php b/app/Http/Controllers/GamesController.php index 54d287365..6bbac73b2 100644 --- a/app/Http/Controllers/GamesController.php +++ b/app/Http/Controllers/GamesController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers; use App\Models\Category; -use Blacklight\Games; +use App\Services\GamesService; use Blacklight\Genres; use Illuminate\Http\Request; @@ -14,7 +14,7 @@ class GamesController extends BasePageController */ public function show(Request $request) { - $games = new Games(['Settings' => $this->settings]); + $games = new GamesService; $gen = new Genres(['Settings' => $this->settings]); $concats = Category::getChildren(Category::PC_ROOT); diff --git a/app/Models/GamesInfo.php b/app/Models/GamesInfo.php index 9c677316b..de494e579 100644 --- a/app/Models/GamesInfo.php +++ b/app/Models/GamesInfo.php @@ -3,6 +3,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Scout\Searchable; /** @@ -24,6 +26,9 @@ use Laravel\Scout\Searchable; * @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $updated_at * + * @property-read Genre|null $genre + * @property-read \Illuminate\Database\Eloquent\Collection|Release[] $releases + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\GamesInfo whereAsin($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\GamesInfo whereBackdrop($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\GamesInfo whereClassused($value) @@ -65,6 +70,32 @@ class GamesInfo extends Model */ protected $guarded = []; + /** + * @var array + */ + protected $casts = [ + 'cover' => 'boolean', + 'backdrop' => 'boolean', + 'genres_id' => 'integer', + 'releasedate' => 'date', + ]; + + /** + * Get the genre for this game. + */ + public function genre(): BelongsTo + { + return $this->belongsTo(Genre::class, 'genres_id'); + } + + /** + * Get the releases for this game. + */ + public function releases(): HasMany + { + return $this->hasMany(Release::class, 'gamesinfo_id'); + } + public function searchableAs(): string { return 'ix_title_ft'; @@ -76,4 +107,30 @@ class GamesInfo extends Model 'title' => $this->title, ]; } + + /** + * Get the cover image path. + */ + public function getCoverPath(): ?string + { + if (!$this->cover) { + return null; + } + + $path = config('nntmux_settings.covers_path') . '/games/' . $this->id . '.jpg'; + return file_exists($path) ? $path : null; + } + + /** + * Get the backdrop image path. + */ + public function getBackdropPath(): ?string + { + if (!$this->backdrop) { + return null; + } + + $path = config('nntmux_settings.covers_path') . '/games/' . $this->id . '-backdrop.jpg'; + return file_exists($path) ? $path : null; + } } diff --git a/app/Services/GamesProcessor.php b/app/Services/GamesProcessor.php index ce9d7932f..8370503f4 100644 --- a/app/Services/GamesProcessor.php +++ b/app/Services/GamesProcessor.php @@ -3,21 +3,23 @@ namespace App\Services; use App\Models\Settings; -use Blacklight\Games; class GamesProcessor { private bool $echooutput; - public function __construct(bool $echooutput) + private GamesService $gamesService; + + public function __construct(bool $echooutput, ?GamesService $gamesService = null) { $this->echooutput = $echooutput; + $this->gamesService = $gamesService ?? new GamesService; } public function process(): void { if ((int) Settings::settingValue('lookupgames') !== 0) { - (new Games)->processGamesReleases(); + $this->gamesService->processGamesReleases(); } } } diff --git a/app/Services/GamesService.php b/app/Services/GamesService.php new file mode 100644 index 000000000..a027797cd --- /dev/null +++ b/app/Services/GamesService.php @@ -0,0 +1,797 @@ +echoOutput = config('nntmux.echocli'); + $this->colorCli = new ColorCLI; + $this->steamService = $steamService ?? new SteamService; + $this->igdbService = $igdbService ?? new IGDBService; + $this->titleParser = $titleParser ?? new GamesTitleParser; + $this->imageService = $imageService ?? new ReleaseImageService; + + $this->gameQty = Settings::settingValue('maxgamesprocessed') !== '' ? (int) Settings::settingValue('maxgamesprocessed') : 150; + $this->imgSavePath = config('nntmux_settings.covers_path') . '/games/'; + $this->renamed = (int) Settings::settingValue('lookupgames') === 2 ? 'AND isrenamed = 1' : ''; + $this->matchPercentage = 60; + $this->maxHitRequest = false; + $this->catWhere = 'AND categories_id = ' . Category::PC_GAMES . ' '; + } + + // ======================================== + // Game Info Retrieval Methods + // ======================================== + + /** + * Get game info by ID. + */ + public function getGamesInfoById(int $id): ?Model + { + return GamesInfo::query() + ->where('gamesinfo.id', $id) + ->leftJoin('genres as g', 'g.id', '=', 'gamesinfo.genres_id') + ->select(['gamesinfo.*', 'g.title as genres']) + ->first(); + } + + /** + * Get game info by name using full-text search. + */ + public function getGamesInfoByName(string $title): bool|Model + { + $bestMatch = false; + + if (empty($title)) { + return false; + } + + $results = GamesInfo::search($title)->get(); + + if ($results instanceof \Traversable) { + $bestMatchPct = 0; + $normQuery = $this->titleParser->normalizeForMatch($title); + + foreach ($results as $result) { + $candidate = is_array($result) ? ($result['title'] ?? '') : ($result->title ?? ''); + if ($candidate === '') { + continue; + } + // Exact match fast-path + if ($candidate === $title) { + $bestMatch = $result; + break; + } + $score = $this->titleParser->computeSimilarity($normQuery, $this->titleParser->normalizeForMatch($candidate)); + if ($score >= self::GAME_MATCH_PERCENTAGE && $score > $bestMatchPct) { + $bestMatch = $result; + $bestMatchPct = $score; + } + } + } + + return $bestMatch; + } + + /** + * Get paginated list of all games. + */ + public function getRange(?string $search = null): LengthAwarePaginator + { + $query = GamesInfo::query() + ->select(['gi.*', 'g.title as genretitle']) + ->from('gamesinfo as gi') + ->join('genres as g', 'gi.genres_id', '=', 'g.id'); + + if (!empty($search)) { + $query->where('gi.title', 'like', '%' . $search . '%'); + } + + return $query->orderByDesc('created_at') + ->paginate(config('nntmux.items_per_page')); + } + + /** + * Get total count of games. + */ + public function getCount(): int + { + return GamesInfo::query()->count(['id']) ?? 0; + } + + /** + * Get games range for browsing with filtering. + * + * @throws \Exception + */ + public function getGamesRange($page, $cat, $start, $num, array|string $orderBy = '', string $maxAge = '', array $excludedCats = []): array + { + $page = max(1, $page); + $start = max(0, $start); + + $browseBy = $this->getBrowseBy(); + $catsrch = ''; + if (count($cat) > 0 && $cat[0] !== -1) { + $catsrch = Category::getCategorySearch($cat); + } + if ($maxAge > 0) { + $maxAge = sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge); + } + $exccatlist = ''; + if (count($excludedCats) > 0) { + $exccatlist = ' AND r.categories_id NOT IN (' . implode(',', $excludedCats) . ')'; + } + $order = $this->getGamesOrder($orderBy); + $gamesSql = + "SELECT SQL_CALC_FOUND_ROWS gi.id, GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id FROM gamesinfo gi LEFT JOIN releases r ON gi.id = r.gamesinfo_id WHERE gi.title != '' AND gi.cover = 1 AND r.passwordstatus " + . app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords() . + $browseBy . + $catsrch . + $maxAge . + $exccatlist . + ' GROUP BY gi.id ORDER BY ' . ($order[0]) . ' ' . ($order[1]) . + ($start === false ? '' : ' LIMIT ' . $num . ' OFFSET ' . $start); + + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + $gamesCache = Cache::get(md5($gamesSql . $page)); + if ($gamesCache !== null) { + $games = $gamesCache; + } else { + $data = DB::select($gamesSql); + $games = ['total' => DB::select('SELECT FOUND_ROWS() AS total'), 'result' => $data]; + Cache::put(md5($gamesSql . $page), $games, $expiresAt); + } + + $gameIDs = $releaseIDs = false; + if (is_array($games['result'])) { + foreach ($games['result'] as $game => $id) { + $gameIDs = [$id->id]; + $releaseIDs = [$id->grp_release_id]; + } + } + + $returnSql = + 'SELECT r.id, r.rarinnerfilecount, r.grabs, r.comments, r.totalpart, r.size, r.postdate, r.searchname, r.haspreview, r.passwordstatus, r.guid, g.name AS group_name, df.failed AS failed, gi.*, YEAR (gi.releasedate) as year, r.gamesinfo_id, rn.releases_id AS nfoid FROM releases r LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id INNER JOIN gamesinfo gi ON gi.id = r.gamesinfo_id WHERE gi.id IN (' . (is_array($gameIDs) ? implode(',', $gameIDs) : -1) . ') AND r.id IN (' . (is_array($releaseIDs) ? implode(',', $releaseIDs) : -1) . ')' . $catsrch . ' GROUP BY gi.id ORDER BY ' . ($order[0]) . ' ' . ($order[1]); + + $return = Cache::get(md5($returnSql . $page)); + if ($return !== null) { + return $return; + } + $return = DB::select($returnSql); + if (count($return) > 0) { + $return[0]->_totalcount = $games['total'][0]->total ?? 0; + } + Cache::put(md5($returnSql . $page), $return, $expiresAt); + + return $return; + } + + /** + * Get order array for games. + */ + public function getGamesOrder(array|string $orderBy): array + { + $order = $orderBy === '' ? 'r.postdate' : $orderBy; + $orderArr = explode('_', $order); + $orderField = match ($orderArr[0]) { + 'title' => 'gi.title', + 'releasedate' => 'gi.releasedate', + 'genre' => 'gi.genres_id', + 'size' => 'r.size', + 'files' => 'r.totalpart', + 'stats' => 'r.grabs', + default => 'r.postdate', + }; + $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc'; + + return [$orderField, $orderSort]; + } + + /** + * Get ordering options. + */ + public function getGamesOrdering(): array + { + return [ + 'title_asc', 'title_desc', 'posted_asc', 'posted_desc', 'size_asc', 'size_desc', + 'files_asc', 'files_desc', 'stats_asc', 'stats_desc', + 'releasedate_asc', 'releasedate_desc', 'genre_asc', 'genre_desc', + ]; + } + + /** + * Get browse by options. + */ + public function getBrowseByOptions(): array + { + return ['title' => 'title', 'genre' => 'genres_id', 'year' => 'year']; + } + + /** + * Get browse by SQL clause. + */ + public function getBrowseBy(): string + { + $browseBy = ' '; + foreach ($this->getBrowseByOptions() as $bbk => $bbv) { + if (!empty($_REQUEST[$bbk])) { + $bbs = stripslashes($_REQUEST[$bbk]); + if ($bbk === 'year') { + $browseBy .= ' AND YEAR (gi.releasedate) ' . 'LIKE ' . escapeString('%' . $bbs . '%'); + } else { + $browseBy .= ' AND gi.' . $bbv . ' ' . 'LIKE ' . escapeString('%' . $bbs . '%'); + } + } + } + + return $browseBy; + } + + // ======================================== + // Game Update Methods + // ======================================== + + /** + * Update a game record. + */ + public function update( + int $id, + string $title, + ?string $asin, + ?string $url, + ?string $publisher, + $releaseDate, + ?string $esrb, + int $cover, + ?string $trailerUrl, + ?int $genreID + ): void { + GamesInfo::query() + ->where('id', $id) + ->update([ + 'title' => $title, + 'asin' => $asin, + 'url' => $url, + 'publisher' => $publisher, + 'releasedate' => $releaseDate, + 'esrb' => $esrb, + 'cover' => $cover, + 'trailer' => $trailerUrl, + 'genres_id' => $genreID, + ]); + } + + /** + * Update game info from external APIs (Steam/IGDB). + */ + public function updateGamesInfo(array $gameInfo): bool|int + { + $gen = new Genres(['Settings' => null]); + + $game = []; + $titleKey = $this->generateCacheKey($gameInfo['title']); + + // Check if we've already failed to find this game recently + if (Cache::has("game_lookup_failed:{$titleKey}")) { + Log::debug('GamesService: Skipping previously failed lookup', ['title' => $gameInfo['title']]); + $this->cachedCount++; + return false; + } + + // Check cache first for existing lookup + $cachedResult = Cache::get("game_lookup:{$titleKey}"); + if ($cachedResult !== null) { + Log::debug('GamesService: Using cached lookup result', ['title' => $gameInfo['title']]); + $this->cachedCount++; + return $this->saveGameInfoFromCache($cachedResult, $gen, $gameInfo); + } + + // Try Steam first (has more details) + $this->_classUsed = 'Steam'; + $genreName = ''; + + $steamGameID = $this->steamService->search($gameInfo['title']); + + if ($steamGameID !== null) { + $steamResults = $this->steamService->getAll($steamGameID); + + if ($steamResults !== false) { + if (empty($steamResults['title'])) { + return false; + } + + $game = $this->buildGameFromSteam($steamResults, $genreName); + } + } + + // Fall back to IGDB if Steam didn't find anything + if ($this->igdbService->isConfigured()) { + try { + if ($steamGameID === null || empty($game)) { + $igdbGame = $this->igdbService->search($gameInfo['title']); + if ($igdbGame !== null) { + $this->_classUsed = 'IGDB'; + $game = $this->igdbService->buildGameData($igdbGame, $genreName); + } else { + Cache::put("game_lookup_failed:{$titleKey}", true, self::FAILED_LOOKUP_CACHE_TTL); + return false; + } + } + } catch (ClientException $e) { + if ($e->getCode() === 429) { + $this->igdbSleep = now()->endOfMonth(); + Log::warning('GamesService: IGDB rate limit exceeded'); + } + } + } + + if (empty($game)) { + Cache::put("game_lookup_failed:{$titleKey}", true, self::FAILED_LOOKUP_CACHE_TTL); + return false; + } + + // Save to database + return $this->saveGameToDatabase($game, $genreName, $gen, $gameInfo, $titleKey); + } + + /** + * Build game array from Steam data. + */ + protected function buildGameFromSteam(array $steamResults, string &$genreName): array + { + $game = []; + + if (!empty($steamResults['cover'])) { + $game['coverurl'] = (string) $steamResults['cover']; + } + + if (!empty($steamResults['backdrop'])) { + $game['backdropurl'] = (string) $steamResults['backdrop']; + } + + $game['title'] = (string) $steamResults['title']; + $game['asin'] = $steamResults['steamid']; + $game['url'] = (string) $steamResults['directurl']; + $game['publisher'] = !empty($steamResults['publisher']) ? (string) $steamResults['publisher'] : 'Unknown'; + $game['esrb'] = !empty($steamResults['rating']) ? (string) $steamResults['rating'] : 'Not Rated'; + + if (!empty($steamResults['releasedate'])) { + $dateReleased = strtotime($steamResults['releasedate']) === false ? '' : $steamResults['releasedate']; + $game['releasedate'] = ($steamResults['releasedate'] === '' || strtotime($steamResults['releasedate']) === false) + ? null + : Carbon::createFromFormat('M j, Y', Carbon::parse($dateReleased)->toFormattedDateString())->format('Y-m-d'); + } + + if (!empty($steamResults['description'])) { + $game['review'] = (string) $steamResults['description']; + } + + if (!empty($steamResults['genres'])) { + $genreName = $this->igdbService->matchGenre($steamResults['genres']); + } + + return $game; + } + + /** + * Save game to database. + */ + protected function saveGameToDatabase(array $game, string $genreName, Genres $gen, array $gameInfo, string $titleKey): bool|int + { + // Load genres + $defaultGenres = $gen->loadGenres(Genres::GAME_TYPE); + + // Prepare database values + $game['cover'] = isset($game['coverurl']) ? 1 : 0; + $game['backdrop'] = isset($game['backdropurl']) ? 1 : 0; + if (!isset($game['trailer'])) { + $game['trailer'] = 0; + } + if (empty($game['title'])) { + $game['title'] = $gameInfo['title']; + } + if (!isset($game['releasedate'])) { + $game['releasedate'] = ''; + } + if (!isset($game['review'])) { + $game['review'] = 'No Review'; + } + $game['classused'] = $this->_classUsed; + + if (empty($genreName)) { + $genreName = 'Unknown'; + } + + if (in_array(strtolower($genreName), $defaultGenres, false)) { + $genreKey = array_search(strtolower($genreName), $defaultGenres, false); + } else { + $genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => Genres::GAME_TYPE]); + } + + $game['gamesgenre'] = $genreName; + $game['gamesgenreID'] = $genreKey; + + $gamesId = false; + + if (!empty($game['asin'])) { + try { + DB::beginTransaction(); + + $check = GamesInfo::query()->where('asin', $game['asin'])->first(); + if ($check === null) { + $gamesId = GamesInfo::query() + ->insertGetId([ + 'title' => $game['title'], + 'asin' => $game['asin'], + 'url' => $game['url'], + 'publisher' => $game['publisher'], + 'genres_id' => $game['gamesgenreID'] === -1 ? null : $game['gamesgenreID'], + 'esrb' => $game['esrb'], + 'releasedate' => $game['releasedate'] !== '' ? $game['releasedate'] : null, + 'review' => substr($game['review'], 0, 3000), + 'cover' => $game['cover'], + 'backdrop' => $game['backdrop'], + 'trailer' => $game['trailer'], + 'classused' => $game['classused'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Log::info('GamesService: Added new game', ['id' => $gamesId, 'title' => $game['title'], 'source' => $this->_classUsed]); + } else { + $gamesId = $check['id']; + GamesInfo::query() + ->where('id', $gamesId) + ->update([ + 'title' => $game['title'], + 'asin' => $game['asin'], + 'url' => $game['url'], + 'publisher' => $game['publisher'], + 'genres_id' => $game['gamesgenreID'] === -1 ? null : $game['gamesgenreID'], + 'esrb' => $game['esrb'], + 'releasedate' => $game['releasedate'] !== '' ? $game['releasedate'] : null, + 'review' => substr($game['review'], 0, 3000), + 'cover' => $game['cover'], + 'backdrop' => $game['backdrop'], + 'trailer' => $game['trailer'], + 'classused' => $game['classused'], + ]); + + Log::debug('GamesService: Updated existing game', ['id' => $gamesId, 'title' => $game['title']]); + } + + DB::commit(); + + // Cache the successful lookup + Cache::put("game_lookup:{$titleKey}", $game, self::GAME_CACHE_TTL); + $this->matchedCount++; + + } catch (\Exception $e) { + DB::rollBack(); + Log::error('GamesService: Database error saving game', [ + 'title' => $game['title'], + 'error' => $e->getMessage() + ]); + return false; + } + } + + if (!empty($gamesId)) { + if ($this->echoOutput) { + $this->colorCli->header('Added/updated game: ') . + $this->colorCli->alternateOver(' Title: ') . + $this->colorCli->primary($game['title']) . + $this->colorCli->alternateOver(' Source: ') . + $this->colorCli->primary($this->_classUsed); + } + + // Save cover image + if ($game['cover'] === 1 && isset($game['coverurl'])) { + $game['cover'] = $this->imageService->saveImage($gamesId, $game['coverurl'], $this->imgSavePath, 250, 250); + } + + // Save backdrop image + if ($game['backdrop'] === 1 && isset($game['backdropurl'])) { + $game['backdrop'] = $this->imageService->saveImage($gamesId . '-backdrop', $game['backdropurl'], $this->imgSavePath, 1920, 1024); + } + } elseif ($this->echoOutput) { + $this->colorCli->headerOver('Nothing to update: ') . + $this->colorCli->primary($game['title'] . ' (PC)'); + } + + return $gamesId !== false ? $gamesId : false; + } + + /** + * Save game info from cached data. + */ + protected function saveGameInfoFromCache(array $game, Genres $gen, array $gameInfo): bool|int + { + $defaultGenres = $gen->loadGenres(Genres::GAME_TYPE); + $genreName = $game['gamesgenre'] ?? 'Unknown'; + + if (in_array(strtolower($genreName), $defaultGenres, false)) { + $genreKey = array_search(strtolower($genreName), $defaultGenres, false); + } else { + $genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => Genres::GAME_TYPE]); + } + + $game['gamesgenreID'] = $genreKey; + + if (!empty($game['asin'])) { + $check = GamesInfo::query()->where('asin', $game['asin'])->first(); + if ($check !== null) { + return $check['id']; + } + } + + return false; + } + + // ======================================== + // Release Processing Methods + // ======================================== + + /** + * Process game releases. + * + * @throws \Exception + */ + public function processGamesReleases(): void + { + // Reset stats + $this->processedCount = 0; + $this->matchedCount = 0; + $this->failedCount = 0; + $this->cachedCount = 0; + + $startTime = microtime(true); + + $query = Release::query() + ->where('gamesinfo_id', '=', 0) + ->where('categories_id', '=', Category::PC_GAMES); + + if ((int) Settings::settingValue('lookupgames') === 2) { + $query->where('isrenamed', '=', 1); + } + + $query->select(['searchname', 'id']) + ->orderByDesc('postdate') + ->limit($this->gameQty); + + $res = $query->get(); + + if ($res->count() > 0) { + if ($this->echoOutput) { + $this->colorCli->header('Processing ' . $res->count() . ' games release(s).'); + } + + Log::info('GamesService: Starting processing', ['count' => $res->count()]); + + $releaseUpdates = []; + + foreach ($res as $arr) { + $this->processedCount++; + $this->maxHitRequest = false; + + $gameInfo = $this->parseTitle($arr['searchname']); + + if ($gameInfo !== false) { + if ($this->echoOutput) { + $this->colorCli->info('Looking up: ' . $gameInfo['title'] . ' (PC)'); + } + + // Check for existing games entry + $gameCheck = $this->getGamesInfoByName($gameInfo['title']); + + if ($gameCheck === false) { + $gameId = $this->updateGamesInfo($gameInfo); + if ($gameId === false) { + $gameId = -2; + $this->failedCount++; + + if ($this->maxHitRequest === true) { + $gameId = 0; + } + } + } else { + $gameId = $gameCheck['id']; + $this->cachedCount++; + } + + $releaseUpdates[$arr['id']] = $gameId; + } else { + $releaseUpdates[$arr['id']] = -2; + $this->failedCount++; + + if ($this->echoOutput) { + echo '.'; + } + } + } + + // Batch update releases + $this->batchUpdateReleases($releaseUpdates); + + $elapsed = round(microtime(true) - $startTime, 2); + + Log::info('GamesService: Processing completed', [ + 'processed' => $this->processedCount, + 'matched' => $this->matchedCount, + 'cached' => $this->cachedCount, + 'failed' => $this->failedCount, + 'elapsed_seconds' => $elapsed, + ]); + + if ($this->echoOutput) { + $this->colorCli->header(sprintf( + 'Games processing complete: %d processed, %d matched, %d cached, %d failed (%.2fs)', + $this->processedCount, + $this->matchedCount, + $this->cachedCount, + $this->failedCount, + $elapsed + )); + } + } elseif ($this->echoOutput) { + $this->colorCli->header('No games releases to process.'); + } + } + + /** + * Parse release title. + * + * @return array{title: string, release: string}|false + */ + public function parseTitle(string $releaseName): array|false + { + return $this->titleParser->parse($releaseName); + } + + /** + * Batch update release records with game IDs. + */ + protected function batchUpdateReleases(array $updates): void + { + if (empty($updates)) { + return; + } + + try { + DB::beginTransaction(); + + foreach ($updates as $releaseId => $gameId) { + Release::query() + ->where('id', '=', $releaseId) + ->where('categories_id', '=', Category::PC_GAMES) + ->update(['gamesinfo_id' => $gameId]); + } + + DB::commit(); + + Log::debug('GamesService: Batch updated releases', ['count' => count($updates)]); + } catch (\Exception $e) { + DB::rollBack(); + Log::error('GamesService: Failed to batch update releases', ['error' => $e->getMessage()]); + + // Fall back to individual updates + foreach ($updates as $releaseId => $gameId) { + try { + Release::query() + ->where('id', '=', $releaseId) + ->where('categories_id', '=', Category::PC_GAMES) + ->update(['gamesinfo_id' => $gameId]); + } catch (\Exception $e) { + Log::error('GamesService: Failed to update release', [ + 'release_id' => $releaseId, + 'error' => $e->getMessage() + ]); + } + } + } + } + + // ======================================== + // Utility Methods + // ======================================== + + /** + * Generate a consistent cache key from a game title. + */ + protected function generateCacheKey(string $title): string + { + return md5(mb_strtolower(trim($title))); + } + + /** + * Get processing statistics from the last run. + */ + public function getProcessingStats(): array + { + return [ + 'processed' => $this->processedCount, + 'matched' => $this->matchedCount, + 'cached' => $this->cachedCount, + 'failed' => $this->failedCount, + ]; + } + + /** + * Clear game lookup caches. + */ + public function clearLookupCaches(): void + { + Log::info('GamesService: Lookup caches cleared'); + } + + /** + * Match genre name to known genres. + */ + public function matchGenreName(string $gameGenre): bool|string + { + return $this->igdbService->isKnownGenre($gameGenre) ? $gameGenre : false; + } +} diff --git a/app/Services/GamesTitleParser.php b/app/Services/GamesTitleParser.php new file mode 100644 index 000000000..b750f7aa8 --- /dev/null +++ b/app/Services/GamesTitleParser.php @@ -0,0 +1,243 @@ +[\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'; + + /** + * Parse a release title and extract the clean game title. + * + * @return array{title: string, release: string}|false + */ + public function parse(string $releaseName): array|false + { + $name = $this->cleanReleaseName($releaseName); + + if ($name === '') { + return $this->fallbackParse($releaseName); + } + + return [ + 'title' => $name, + 'release' => $releaseName, + ]; + } + + /** + * Clean release name by removing scene artifacts. + */ + protected function cleanReleaseName(string $name): string + { + // Remove simple file extensions at the end + $name = (string) preg_replace('/\.(zip|rar|7z|iso|nfo|sfv|exe|mkv|mp4|avi)$/i', '', $name); + $name = str_replace('%20', ' ', $name); + + // Remove leading bracketed tag like [GROUP] or [PC] + $name = (string) preg_replace('/^\[[^]]+\]\s*/', '', $name); + + // Remove "PC ISO) (" artifact + $name = (string) preg_replace('/^(PC\s*ISO\)\s*\()/i', '', $name); + + // Remove common bracketed/parenthesized tags (languages, qualities, platforms, versions) + $name = (string) preg_replace('/\[[^]]{1,80}\]|\([^)]{1,80}\)/', ' ', $name); + + // Remove edition and extra tags + $name = (string) preg_replace( + '/\b(Game\s+of\s+the\s+Year|GOTY|Definitive Edition|Deluxe Edition|Ultimate Edition|Complete Edition|Remastered|HD Remaster|Directors? Cut|Anniversary Edition)\b/i', + ' ', + $name + ); + + // Remove update/patch followed by version numbers + $name = (string) preg_replace('/\b(Update|Patch|Hotfix)\b[\s._-]*v?\d+(?:\.\d+){1,}\b/i', ' ', $name); + + // Remove MULTI tokens + $name = (string) preg_replace('/\bMULTI\d+|MULTi\d+\b/i', ' ', $name); + + // Remove version tokens + $name = (string) preg_replace('/(?:^|[\s._-])v\d+(?:[\s._-]\d+){1,}(?=$|[\s._-])/i', ' ', $name); + $name = (string) preg_replace('/(?:^|[\s._-])\d+(?:[\s._-]\d+){2,}(?=$|[\s._-])/', ' ', $name); + + // Remove other common release tags + $name = (string) preg_replace('/\b(Incl(?:uding)?\s+DLCs?|DLCs?|PROPER|REPACK|RIP|ISO|CRACK(?:FIX)?|BETA|ALPHA)\b/i', ' ', $name); + $name = (string) preg_replace('/\b(Update|Patch|Hotfix)\b/i', ' ', $name); + + // Remove scene group suffix + $groupAlternation = implode('|', array_map('preg_quote', self::SCENE_GROUPS)); + $name = (string) preg_replace('/\s*-\s*(?:' . $groupAlternation . '|[A-Z0-9]{2,})\s*$/i', '', $name); + + // Replace separators with spaces + $name = (string) preg_replace('/[._+]+/', ' ', $name); + + // Second pass: remove edition tokens now that separators are normalized + $name = (string) preg_replace( + '/\b(Game\s+of\s+the\s+Year|GOTY|Definitive Edition|Deluxe Edition|Ultimate Edition|Complete Edition|Remastered|HD Remaster|Directors? Cut|Anniversary Edition)\b/i', + ' ', + $name + ); + $name = (string) preg_replace('/\b(Incl|Including|DLC|DLCs)\b/i', ' ', $name); + + // Token-based cleanup for version sequences + $name = $this->cleanVersionTokens($name); + + // Remove spaced-out version sequences + $name = (string) preg_replace('/(?:^|\s)v\d+(?:\s+\d+){1,}(?=$|\s)/i', ' ', $name); + $name = (string) preg_replace('/(?:^|\s)\d+(?:\s+\d+){2,}(?=$|\s)/', ' ', $name); + + // Collapse multiple spaces and trim + $name = (string) preg_replace('/\s{2,}/', ' ', $name); + $name = trim($name, " \t\n\r\0\x0B-_"); + + // Special fix from previous implementation + $name = str_replace(' RF ', ' ', $name); + + // Final aggressive cleanup + for ($i = 0; $i < 2; $i++) { + $name = (string) preg_replace('/(?:^|\s)v\d+(?:\s+\d+){1,}(?=$|\s)/i', ' ', $name); + $name = (string) preg_replace('/(?:^|\s)\d+(?:\s+\d+){2,}(?=$|\s)/', ' ', $name); + $name = (string) preg_replace('/\b[vV]\d+(?:[ ._-]\d+){1,}\b/', ' ', $name); + $name = (string) preg_replace('/\b\d+(?:[ ._-]\d+){2,}\b/', ' ', $name); + $name = (string) preg_replace('/\s{2,}/', ' ', $name); + $name = trim($name, " \t\n\r\0\x0B-_"); + } + + return $name; + } + + /** + * Clean version token sequences from name. + */ + protected function cleanVersionTokens(string $name): string + { + $tokens = preg_split('/\s+/', trim($name)) ?: []; + $filtered = []; + $i = 0; + $tcount = count($tokens); + + while ($i < $tcount) { + $tok = $tokens[$i]; + if (preg_match('/^v?\d+$/i', $tok)) { + $j = $i + 1; + $numRun = 0; + while ($j < $tcount && preg_match('/^\d+$/', $tokens[$j])) { + $numRun++; + $j++; + } + $startsWithV = preg_match('/^v\d+$/i', $tok) === 1; + if (($startsWithV && $numRun >= 1) || (!$startsWithV && $numRun >= 2)) { + // Skip this version run + $i = $j; + continue; + } + // Not a version run: keep the single number token + $filtered[] = $tok; + $i++; + continue; + } + $filtered[] = $tok; + $i++; + } + + return implode(' ', $filtered); + } + + /** + * Fallback to legacy regex parsing. + * + * @return array{title: string, release: string}|false + */ + protected function fallbackParse(string $releaseName): array|false + { + $cleanedName = preg_replace('/\sMulti\d?\s/i', '', $releaseName); + if (preg_match(self::LEGACY_TITLE_REGEX, $cleanedName, $hits)) { + $result = []; + $result['title'] = str_replace(' RF ', ' ', preg_replace('/(?:[-:._]|%20|[\[\]])/', ' ', $hits['title'])); + $result['title'] = preg_replace('/(brazilian|chinese|croatian|danish|deutsch|dutch|english|estonian|flemish|finnish|french|german|greek|hebrew|icelandic|italian|latin|nordic|norwegian|polish|portuguese|japenese|japanese|russian|serbian|slovenian|spanish|spanisch|swedish|thai|turkish)$/i', '', $result['title']); + $result['title'] = preg_replace('/^(PC\sISO\)\s\()/i', '', $result['title']); + $result['title'] = trim(preg_replace('/\s{2,}/', ' ', $result['title'])); + + if (empty($result['title'])) { + return false; + } + + $result['release'] = $releaseName; + + return array_map('trim', $result); + } + + return false; + } + + /** + * Normalize a title for comparison/matching. + */ + public function normalizeForMatch(string $title): string + { + $t = mb_strtolower($title); + + // Strip scene groups at end + $groupAlternation = implode('|', array_map('preg_quote', self::SCENE_GROUPS)); + $t = (string) preg_replace('/\s*-\s*(?:' . $groupAlternation . '|[A-Z0-9]{2,})\s*$/i', '', $t); + + // Remove edition tokens and common noise + $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); + + // Remove languages/platform tokens + $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); + + // Remove punctuation + $t = (string) preg_replace('/[^a-z0-9]+/i', ' ', $t); + $t = trim(preg_replace('/\s{2,}/', ' ', $t)); + + return $t; + } + + /** + * Compute similarity between two strings. + */ + public function computeSimilarity(string $a, string $b): float + { + if ($a === $b) { + return 100.0; + } + + $percent = 0.0; + similar_text($a, $b, $percent); + + // Levenshtein-based score + $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); + } +} diff --git a/app/Services/IGDBService.php b/app/Services/IGDBService.php new file mode 100644 index 000000000..d5872c95b --- /dev/null +++ b/app/Services/IGDBService.php @@ -0,0 +1,693 @@ +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); + + if ($game !== null) { + Cache::put($cacheKey, $game, self::GAME_CACHE_TTL); + Log::info('IGDBService: Found match', ['title' => $title, 'matched' => $game->name]); + } else { + Cache::put("igdb_search_failed:{$cacheKey}", true, self::FAILED_LOOKUP_CACHE_TTL); + Log::debug('IGDBService: No match found', ['title' => $title]); + } + + return $game; + } + + /** + * 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 + */ + 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): ?Game + { + // Strategy 1: Exact name search with PC platform filter + $game = $this->searchExact($title); + if ($game !== null) { + Log::debug('IGDBService: Exact match found', ['title' => $title, 'matched' => $game->name]); + return $game; + } + + // Strategy 2: Fuzzy search using IGDB's search endpoint + $game = $this->searchFuzzy($title); + if ($game !== null) { + Log::debug('IGDBService: 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 && $cleanTitle !== '') { + $game = $this->searchFuzzy($cleanTitle); + if ($game !== null) { + Log::debug('IGDBService: Clean title match found', ['title' => $title, 'matched' => $game->name]); + return $game; + } + } + + // Strategy 4: Try with common subtitle patterns removed + $baseTitle = $this->extractBaseTitle($title); + if ($baseTitle !== $title && $baseTitle !== $cleanTitle && $baseTitle !== '') { + $game = $this->searchFuzzy($baseTitle); + if ($game !== null) { + Log::debug('IGDBService: Base title match found', ['title' => $title, 'matched' => $game->name]); + return $game; + } + } + + return null; + } + + /** + * Exact name search on IGDB with PC platform filter. + */ + protected function searchExact(string $title): ?Game + { + try { + $result = RateLimiter::attempt( + self::RATE_LIMIT_KEY, + self::REQUESTS_PER_MINUTE, + function () use ($title) { + return Game::where('name', $title) + ->whereIn('platforms', self::PC_PLATFORM_IDS) + ->with($this->getGameRelations()) + ->orderByDesc('aggregated_rating_count') + ->first(); + }, + self::DECAY_SECONDS + ); + + return $result instanceof Game ? $result : null; + } 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): ?Game + { + try { + $results = RateLimiter::attempt( + self::RATE_LIMIT_KEY, + self::REQUESTS_PER_MINUTE, + function () use ($title) { + return Game::search($title) + ->whereIn('platforms', self::PC_PLATFORM_IDS) + ->where('category', 0) // Main game only (not DLC, expansion, etc.) + ->with($this->getGameRelations()) + ->orderByDesc('aggregated_rating_count') + ->limit(10) + ->get(); + }, + self::DECAY_SECONDS + ); + + if ($results === true || empty($results)) { + return null; + } + + return $this->findBestMatch($results, $title); + } catch (\Exception $e) { + Log::warning('IGDBService: Fuzzy search error', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * Get relations to load with IGDB queries. + */ + 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($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::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 game data array from IGDB Game model. + */ + 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 \Illuminate\Support\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)); + + // Build review text + $review = $this->buildReview($game, $developers); + + Log::info('IGDBService: Game data built', [ + 'title' => $game->name, + 'id' => $game->id, + 'has_cover' => !empty($coverUrl), + 'has_backdrop' => !empty($backdropUrl), + 'genres' => $genres, + ]); + + return [ + 'title' => $game->name, + 'asin' => 'igdb-' . $game->id, + '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. + */ + protected function extractGenres(Game $game): array + { + $genres = []; + if (!empty($game->genres)) { + $gameGenres = $game->genres; + if ($gameGenres instanceof \Illuminate\Support\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 \Illuminate\Support\Collection) { + $gameThemes = $gameThemes->toArray(); + } + foreach ($gameThemes as $theme) { + $genres[] = is_array($theme) ? ($theme['name'] ?? '') : ($theme->name ?? ''); + } + } + + return array_filter($genres); + } + + /** + * Get properly formatted IGDB image URL. + */ + 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 (strpos($url, '//') === 0) { + $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 \Illuminate\Support\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 \Illuminate\Support\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 \Illuminate\Support\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 \Illuminate\Support\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. + */ + 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 \Illuminate\Support\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); + if (is_array($tmpGenre)) { + 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]); + } + } else { + $genreName = $genre; + } + + 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'); + } +} diff --git a/misc/testing/PostProc/getGameCovers.php b/misc/testing/PostProc/getGameCovers.php index 4fa2a0fc8..a7bb99e23 100644 --- a/misc/testing/PostProc/getGameCovers.php +++ b/misc/testing/PostProc/getGameCovers.php @@ -4,12 +4,12 @@ require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php'; +use App\Services\GamesService; use Blacklight\ColorCLI; -use Blacklight\Games; use Illuminate\Support\Facades\DB; $pdo = DB::connection()->getPdo(); -$game = new Games(['Echo' => true]); +$game = new GamesService; $colorCli = new ColorCLI; $res = $pdo->query( @@ -34,7 +34,7 @@ if ($total > 0) { } } - // amazon limits are 1 per 1 sec + // Rate limiting - 1 per second $diff = floor((now()->timestamp - $starttime) * 1000000); if (1000000 - $diff > 0) { $colorCli->alternate('Sleeping');