mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 23:01:29 +00:00
947 lines
32 KiB
PHP
947 lines
32 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\ImageAssetProfile;
|
|
use App\Enums\SecondarySearchIndex;
|
|
use App\Facades\Search;
|
|
use App\Models\Category;
|
|
use App\Models\GamesInfo;
|
|
use App\Models\Genre;
|
|
use App\Models\Release;
|
|
use App\Models\Settings;
|
|
use App\Services\IGDB\Exceptions\IgdbHttpException;
|
|
use App\Services\Releases\ReleaseBrowseService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* GamesService - Comprehensive PC Games processing service.
|
|
*
|
|
* Features:
|
|
* - Game info retrieval and management
|
|
* - Release processing with Steam and IGDB lookup
|
|
* - Title parsing and matching
|
|
* - Browse/search functionality
|
|
* - Cover image handling
|
|
*/
|
|
class GamesService
|
|
{
|
|
protected const int GAME_MATCH_PERCENTAGE = 85;
|
|
|
|
protected const int GAME_CACHE_TTL = 86400; // 24 hours
|
|
|
|
protected const int FAILED_LOOKUP_CACHE_TTL = 3600; // 1 hour
|
|
|
|
public bool $echoOutput;
|
|
|
|
public string|int|null $gameQty;
|
|
|
|
public string $imgSavePath;
|
|
|
|
public int $matchPercentage;
|
|
|
|
public bool $maxHitRequest;
|
|
|
|
public string $renamed;
|
|
|
|
public string $catWhere;
|
|
|
|
protected SteamService $steamService;
|
|
|
|
protected IGDBService $igdbService;
|
|
|
|
protected GamesTitleParser $titleParser;
|
|
|
|
protected ReleaseImageService $imageService;
|
|
|
|
// Processing stats
|
|
protected int $processedCount = 0;
|
|
|
|
protected int $matchedCount = 0;
|
|
|
|
protected int $failedCount = 0;
|
|
|
|
protected int $cachedCount = 0;
|
|
|
|
protected string $_classUsed = '';
|
|
|
|
/**
|
|
* @throws \Exception
|
|
*/
|
|
public function __construct(
|
|
?SteamService $steamService = null,
|
|
?IGDBService $igdbService = null,
|
|
?GamesTitleParser $titleParser = null,
|
|
?ReleaseImageService $imageService = null
|
|
) {
|
|
$this->echoOutput = config('nntmux.echocli');
|
|
$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;
|
|
}
|
|
|
|
if (Search::isAvailable()) {
|
|
$hits = Search::searchSecondary(SecondarySearchIndex::Games, $title, 80);
|
|
$bestMatchPct = 0;
|
|
$normQuery = $this->titleParser->normalizeForMatch($title);
|
|
|
|
foreach ($hits['id'] as $gid) {
|
|
$result = GamesInfo::query()
|
|
->where('gamesinfo.id', $gid)
|
|
->leftJoin('genres as g', 'g.id', '=', 'gamesinfo.genres_id')
|
|
->select(['gamesinfo.*', 'g.title as genres'])
|
|
->first();
|
|
if ($result === null) {
|
|
continue;
|
|
}
|
|
$candidate = (string) $result->title;
|
|
if ($candidate === '') {
|
|
continue;
|
|
}
|
|
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;
|
|
}
|
|
|
|
$results = GamesInfo::query()
|
|
->whereRaw('MATCH (title) AGAINST (? IN NATURAL LANGUAGE MODE)', [$title])
|
|
->get();
|
|
|
|
$bestMatchPct = 0;
|
|
$normQuery = $this->titleParser->normalizeForMatch($title);
|
|
|
|
foreach ($results as $result) {
|
|
$candidate = (string) $result->title;
|
|
if ($candidate === '') {
|
|
continue;
|
|
}
|
|
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 // @phpstan-ignore missingType.generics
|
|
{
|
|
$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)) {
|
|
if (Search::isAvailable()) {
|
|
$ids = Search::searchSecondary(SecondarySearchIndex::Games, $search, 3000)['id'];
|
|
if ($ids === []) {
|
|
return $query->whereRaw('1 = 0')->paginate(config('nntmux.items_per_page'));
|
|
}
|
|
$query->whereIn('gi.id', $ids);
|
|
} else {
|
|
$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.
|
|
*
|
|
* @param array<string, mixed> $excludedCats
|
|
* @param array<string, mixed> $orderBy
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function getGamesRange(mixed $page, mixed $cat, mixed $start, mixed $num, array|string $orderBy = '', string $maxAge = '', array $excludedCats = []): mixed
|
|
{
|
|
$page = max(1, $page);
|
|
$start = max(0, $start);
|
|
|
|
$browseBy = $this->getBrowseBy();
|
|
$catsrch = '';
|
|
if (count($cat) > 0 && $cat[0] !== -1) {
|
|
$catsrch = Category::getCategorySearch($cat);
|
|
}
|
|
$whereAge = '';
|
|
if ($maxAge > 0) {
|
|
$whereAge = 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);
|
|
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
|
|
$showPasswords = app(ReleaseBrowseService::class)->showPasswords();
|
|
|
|
$baseWhere = "gi.title != '' AND gi.cover = 1 "
|
|
."AND r.passwordstatus {$showPasswords} "
|
|
.$browseBy.' '
|
|
.$catsrch.' '
|
|
.$whereAge
|
|
.$exccatlist;
|
|
|
|
$cacheKey = md5('games_range_'.$baseWhere.$order[0].$order[1].$start.$num.$page);
|
|
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
// Step 1: Count total distinct games matching filters
|
|
$countSql = 'SELECT COUNT(DISTINCT gi.id) AS total '
|
|
.'FROM gamesinfo gi '
|
|
.'INNER JOIN releases r ON gi.id = r.gamesinfo_id '
|
|
.'WHERE '.$baseWhere;
|
|
|
|
$totalResult = DB::select($countSql);
|
|
$totalCount = $totalResult[0]->total ?? 0;
|
|
|
|
if ($totalCount === 0) {
|
|
return collect();
|
|
}
|
|
|
|
// Step 2: Get paginated games entity list with only needed columns
|
|
$gamesSql = 'SELECT gi.id, gi.title, gi.cover, gi.publisher, gi.releasedate, gi.review, gi.url, '
|
|
.'YEAR(gi.releasedate) AS year, '
|
|
.'MAX(r.postdate) AS latest_postdate, '
|
|
.'COUNT(r.id) AS total_releases '
|
|
.'FROM gamesinfo gi '
|
|
.'INNER JOIN releases r ON gi.id = r.gamesinfo_id '
|
|
.'WHERE '.$baseWhere.' '
|
|
.'GROUP BY gi.id, gi.title, gi.cover, gi.publisher, gi.releasedate, gi.review, gi.url '
|
|
."ORDER BY {$order[0]} {$order[1]} "
|
|
."LIMIT {$num} OFFSET {$start}";
|
|
|
|
$games = GamesInfo::fromQuery($gamesSql);
|
|
|
|
if ($games->isEmpty()) {
|
|
return collect();
|
|
}
|
|
|
|
// Build list of game IDs for release query
|
|
$gameIds = $games->pluck('id')->toArray();
|
|
$inGameIds = implode(',', array_map('intval', $gameIds));
|
|
|
|
// Step 3: Get top 2 releases per game using ROW_NUMBER()
|
|
$releasesSql = 'SELECT ranked.id, ranked.gamesinfo_id, ranked.guid, ranked.searchname, '
|
|
.'ranked.size, ranked.postdate, ranked.adddate, ranked.haspreview, ranked.grabs, '
|
|
.'ranked.comments, ranked.totalpart, ranked.group_name, ranked.nfoid, ranked.failed_count '
|
|
.'FROM ( '
|
|
.'SELECT r.id, r.gamesinfo_id, r.guid, r.searchname, r.size, r.postdate, r.adddate, '
|
|
.'r.haspreview, r.grabs, r.comments, r.totalpart, g.name AS group_name, '
|
|
.'rn.releases_id AS nfoid, df.failed AS failed_count, '
|
|
.'ROW_NUMBER() OVER (PARTITION BY r.gamesinfo_id ORDER BY r.postdate DESC) AS rn '
|
|
.'FROM releases r '
|
|
.'LEFT JOIN usenet_groups g ON g.id = r.groups_id '
|
|
.'LEFT JOIN release_nfos rn ON rn.releases_id = r.id '
|
|
.'LEFT JOIN dnzb_failures df ON df.release_id = r.id '
|
|
."WHERE r.gamesinfo_id IN ({$inGameIds}) "
|
|
."AND r.passwordstatus {$showPasswords} "
|
|
.$catsrch.' '
|
|
.$whereAge
|
|
.$exccatlist
|
|
.') ranked '
|
|
.'WHERE ranked.rn <= 2 '
|
|
.'ORDER BY ranked.gamesinfo_id, ranked.postdate DESC';
|
|
|
|
$releases = DB::select($releasesSql);
|
|
|
|
// Group releases by gamesinfo_id for fast lookup
|
|
$releasesByGame = [];
|
|
foreach ($releases as $release) {
|
|
$releasesByGame[$release->gamesinfo_id][] = $release;
|
|
}
|
|
|
|
// Attach releases to each game entity
|
|
foreach ($games as $game) {
|
|
$game->releases = $releasesByGame[$game->id] ?? []; // @phpstan-ignore assign.propertyReadOnly
|
|
}
|
|
|
|
// Set total count on first item
|
|
if ($games->isNotEmpty()) {
|
|
$games[0]->_totalcount = $totalCount; // @phpstan-ignore property.notFound
|
|
}
|
|
|
|
Cache::put($cacheKey, $games, $expiresAt);
|
|
|
|
return $games;
|
|
}
|
|
|
|
/**
|
|
* Get order array for games.
|
|
*
|
|
* @param array<string, mixed>|string $orderBy
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
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.
|
|
*
|
|
* @return array<int, 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',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get browse by options.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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,
|
|
mixed $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).
|
|
*
|
|
* @param array<string, mixed> $gameInfo
|
|
*/
|
|
public function updateGamesInfo(array $gameInfo): bool|int
|
|
{
|
|
$gen = new GenreService;
|
|
|
|
$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 (IgdbHttpException $e) {
|
|
if ($e->getStatusCode() === 429) {
|
|
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.
|
|
*
|
|
* @param array<string, mixed> $steamResults
|
|
* @return array<string, mixed>
|
|
*/
|
|
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'] = (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.
|
|
*
|
|
* @param array<string, mixed> $game
|
|
* @param array<string, mixed> $gameInfo
|
|
*/
|
|
protected function saveGameToDatabase(array $game, string $genreName, GenreService $gen, array $gameInfo, string $titleKey): bool|int
|
|
{
|
|
// Load genres
|
|
$defaultGenres = $gen->loadGenres((string) GenreService::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, true)) {
|
|
$genreKey = array_search(strtolower($genreName), $defaultGenres, true);
|
|
} else {
|
|
$genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => GenreService::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) {
|
|
cli()->header('Added/updated game: ').
|
|
cli()->alternateOver(' Title: ').
|
|
cli()->primary($game['title']).
|
|
cli()->alternateOver(' Source: ').
|
|
cli()->primary($this->_classUsed);
|
|
}
|
|
|
|
// Save cover image
|
|
if ($game['cover'] === 1 && isset($game['coverurl'])) {
|
|
$game['cover'] = (int) $this->imageService->saveRemoteImage(
|
|
(string) $gamesId,
|
|
$game['coverurl'],
|
|
$this->imgSavePath,
|
|
ImageAssetProfile::MetadataCover,
|
|
)->success;
|
|
}
|
|
|
|
// Save backdrop image
|
|
if ($game['backdrop'] === 1 && isset($game['backdropurl'])) {
|
|
$game['backdrop'] = (int) $this->imageService->saveRemoteImage(
|
|
$gamesId.'-backdrop',
|
|
$game['backdropurl'],
|
|
$this->imgSavePath,
|
|
ImageAssetProfile::Backdrop,
|
|
)->success;
|
|
}
|
|
} elseif ($this->echoOutput) {
|
|
cli()->headerOver('Nothing to update: ').
|
|
cli()->primary($game['title'].' (PC)');
|
|
}
|
|
|
|
return $gamesId !== false ? $gamesId : false;
|
|
}
|
|
|
|
/**
|
|
* Save game info from cached data.
|
|
*
|
|
* @param array<string, mixed> $game
|
|
* @param array<string, mixed> $gameInfo
|
|
*/
|
|
protected function saveGameInfoFromCache(array $game, GenreService $gen, array $gameInfo): bool|int
|
|
{
|
|
$defaultGenres = $gen->loadGenres((string) GenreService::GAME_TYPE);
|
|
$genreName = $game['gamesgenre'] ?? 'Unknown';
|
|
|
|
if (in_array(strtolower($genreName), $defaultGenres, true)) {
|
|
$genreKey = array_search(strtolower($genreName), $defaultGenres, true);
|
|
} else {
|
|
$genreKey = Genre::query()->insertGetId(['title' => $genreName, 'type' => GenreService::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(string $groupID = '', string $guidChar = ''): 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 ($guidChar !== '') {
|
|
$query->where('leftguid', 'like', $guidChar.'%');
|
|
}
|
|
|
|
if ($groupID !== '') {
|
|
$query->where('groups_id', $groupID);
|
|
}
|
|
|
|
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) {
|
|
cli()->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) {
|
|
cli()->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++;
|
|
}
|
|
} 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) {
|
|
cli()->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) {
|
|
cli()->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.
|
|
*
|
|
* @param array<string, mixed> $updates
|
|
*/
|
|
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.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
}
|