[\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 $_getGame; protected int $_resultsFound = 0; public string $catWhere; protected $igdbSleep; protected ColorCLI $colorCli; /** * Games constructor. * * * @throws \Exception */ public function __construct() { $this->echoOutput = config('nntmux.echocli'); $this->colorCli = new ColorCLI; $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 " .(new Releases)->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 { // wait 10 seconds before proceeding (steam api limit) sleep(10); $gen = new Genres(['Settings' => null]); $ri = new ReleaseImage; $game = []; // Process Steam first as Steam has more details $this->_gameResults = false; $genreName = ''; $this->_getGame = new Steam(['DB' => null]); $this->_classUsed = 'Steam'; $steamGameID = $this->_getGame->search($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) { $bestMatch = false; $this->_classUsed = 'IGDB'; $result = Game::where('name', $gameInfo['title'])->get(); if (! empty($result)) { $bestMatchPct = 0; $normQuery = $this->normalizeForMatch($gameInfo['title']); foreach ($result as $res) { $score1 = $this->computeSimilarity($normQuery, $this->normalizeForMatch($res->name)); if ($score1 >= self::GAME_MATCH_PERCENTAGE && $score1 > $bestMatchPct) { $bestMatch = $res->id; $bestMatchPct = $score1; } } if ($bestMatch !== false) { $this->_gameResults = Game::with([ 'cover' => ['url'], 'screenshots' => ['url'], 'involved_companies' => ['company', 'publisher'], 'themes', ])->where('id', $bestMatch)->first(); $publishers = []; if (! empty($this->_gameResults->involved_companies)) { foreach ($this->_gameResults->involved_companies as $publisher) { if ($publisher['publisher'] === true) { $company = Company::find($publisher['company']); $publishers[] = $company['name']; } } } $genres = []; if (! empty($this->_gameResults->themes)) { foreach ($this->_gameResults->themes as $theme) { $genres[] = $theme['name']; } } $genreName = $this->_matchGenre(implode(',', $genres)); $releaseDate = now()->format('Y-m-d'); if (isset($this->_gameResults->first_release_date) && strtotime($this->_gameResults->first_release_date) !== false) { $releaseDate = $this->_gameResults->first_release_date->format('Y-m-d'); } $game = [ 'title' => $this->_gameResults->name, 'asin' => $this->_gameResults->id, 'review' => $this->_gameResults->summary ?? '', 'coverurl' => isset($this->_gameResults->cover) ? 'https:'.$this->_gameResults->cover['url'] : '', 'releasedate' => $releaseDate, 'esrb' => isset($this->_gameResults->aggregated_rating) ? round($this->_gameResults->aggregated_rating).'%' : 'Not Rated', 'url' => $this->_gameResults->url ?? '', 'backdropurl' => isset($this->_gameResults->screenshots) ? 'https:'.str_replace('t_thumb', 't_cover_big', $this->_gameResults->screenshots[0]['url']) : '', 'publisher' => ! empty($publishers) ? implode(',', $publishers) : 'Unknown', ]; } else { $this->colorCli->notice('IGDB returned no valid results'); return false; } } else { $this->colorCli->notice('IGDB found no valid results'); return false; } } } catch (ClientException $e) { if ($e->getCode() === 429) { $this->igdbSleep = now()->endOfMonth(); } } } // 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'])) { $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(), ] ); } 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'], ] ); } } 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 { $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).'); } foreach ($res as $arr) { // Reset maxhitrequest $this->maxHitRequest = false; $gameInfo = $this->parseTitle($arr['searchname']); if ($gameInfo !== false) { if ($this->echoOutput) { $this->colorCli->climate()->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; // Leave gamesinfo_id 0 to parse again if ($this->maxHitRequest === true) { $gameId = 0; } } } else { $gameId = $gameCheck['id']; } // Update release. Release::query()->where('id', '=', $arr['id'])->where('categories_id', '=', Category::PC_GAMES)->update(['gamesinfo_id' => $gameId]); } else { // Could not parse release title. Release::query()->where('id', '=', $arr['id'])->where('categories_id', '=', Category::PC_GAMES)->update(['gamesinfo_id' => -2]); if ($this->echoOutput) { echo '.'; } } } } 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, ]; } /** * See if genre name exists. */ public function matchGenreName($gameGenre): bool|string { $str = ''; // Game genres 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 'Simulation': case 'Sports': case 'Strategy': case 'Trivia': $str = $gameGenre; break; } return ($str !== '') ? $str : false; } 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); } }