Files
newznab-tmux/app/Services/IGDBService.php
T
2026-03-26 10:48:12 +01:00

923 lines
31 KiB
PHP

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