backfill command and minor fixes

This commit is contained in:
Doar Mihai
2026-05-31 16:14:40 +03:00
parent b99ad721da
commit ea3e226398
5 changed files with 221 additions and 90 deletions
@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\UpsertsGames;
use App\Services\IgdbService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class BackfillGamesCommand extends Command
{
use UpsertsGames;
protected $signature = 'games:backfill
{--start= : Override the starting offset (ignores saved progress)}
{--total=50000 : Stop after importing this many games total}
{--batch=500 : Games per IGDB request (max 500)}
{--sleep=2 : Seconds to sleep between batches}
{--reset : Clear saved progress and start from 0}
{--min-ratings=5 : Minimum rating_count to include}';
protected $description = 'Bulk-import games from IGDB, resuming from last saved offset if interrupted';
private const PROGRESS_KEY = 'games_backfill_offset';
public function handle(IgdbService $igdb): int
{
$batch = min((int) $this->option('batch'), 500);
$total = (int) $this->option('total');
$sleep = (int) $this->option('sleep');
$minRatings = (int) $this->option('min-ratings');
if ($this->option('reset')) {
Cache::forget(self::PROGRESS_KEY);
$this->info('Progress reset.');
}
$startOption = $this->option('start');
$offset = $startOption !== null
? (int) $startOption
: (int) Cache::get(self::PROGRESS_KEY, 0);
$this->info("Starting backfill — offset: {$offset}, target: {$total} games, batch: {$batch}, sleep: {$sleep}s");
$this->newLine();
$imported = 0;
$skipped = 0;
$batchNum = 0;
while ($imported < $total) {
$batchNum++;
$limit = min($batch, $total - $imported);
$this->line("Batch #{$batchNum} — offset {$offset}, fetching {$limit}…");
try {
$results = $igdb->getPopularGamesForBackfill($limit, $offset, $minRatings);
} catch (\Exception $e) {
$this->error('IGDB request failed: ' . $e->getMessage());
$this->warn("Progress saved at offset {$offset}. Re-run to resume.");
Cache::put(self::PROGRESS_KEY, $offset);
return self::FAILURE;
}
if (empty($results)) {
$this->info('IGDB returned no more results. Backfill complete.');
Cache::forget(self::PROGRESS_KEY);
break;
}
$batchImported = 0;
foreach ($results as $data) {
try {
$this->upsertGame($data, $igdb);
$batchImported++;
} catch (\Exception $e) {
$skipped++;
$this->warn(" Skipped igdb_id={$data['id']}: " . $e->getMessage());
}
}
$imported += $batchImported;
$offset += count($results);
Cache::put(self::PROGRESS_KEY, $offset);
$this->line(" → {$batchImported} upserted | total so far: {$imported} | next offset: {$offset}");
if ($imported < $total && count($results) === $limit) {
sleep($sleep);
}
}
$this->newLine();
$this->info("Backfill finished. Imported/updated: {$imported}, skipped: {$skipped}.");
if ($imported >= $total) {
$this->comment("Reached --total={$total}. Run again (or with a higher --total) to continue from offset {$offset}.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Console\Commands\Concerns;
use App\Models\Company;
use App\Models\Game;
use App\Models\GameScreenshot;
use App\Models\Genre;
use App\Models\Platform;
use App\Services\IgdbService;
trait UpsertsGames
{
protected function upsertGame(array $data, IgdbService $igdb): void
{
$igdbId = $data['id'];
$title = $data['name'] ?? 'Unknown';
$releaseDate = isset($data['first_release_date'])
? date('Y-m-d', $data['first_release_date'])
: null;
$releaseYear = $releaseDate ? substr($releaseDate, 0, 4) : null;
$coverUrl = null;
$coverThumbUrl = null;
if (!empty($data['cover']['image_id'])) {
$coverUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_big');
$coverThumbUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_small');
}
$game = Game::updateOrCreate(
['igdb_id' => $igdbId],
[
'title' => $title,
'summary' => $data['summary'] ?? null,
'cover_url' => $coverUrl,
'cover_thumb_url' => $coverThumbUrl,
'release_date' => $releaseDate,
'release_year' => $releaseYear,
'status' => 'published',
]
);
if (!empty($data['genres'])) {
$genreIds = [];
foreach ($data['genres'] as $g) {
$genre = Genre::firstOrCreate(
['igdb_id' => $g['id']],
['name' => $g['name'], 'slug' => \Str::slug($g['name'])]
);
$genreIds[] = $genre->id;
}
$game->genres()->sync($genreIds);
}
if (!empty($data['platforms'])) {
$platformIds = [];
foreach ($data['platforms'] as $p) {
$platform = Platform::firstOrCreate(
['igdb_id' => $p['id']],
['name' => $p['name'], 'slug' => \Str::slug($p['name'])]
);
$platformIds[] = $platform->id;
}
$game->platforms()->sync($platformIds);
}
if (!empty($data['involved_companies'])) {
$companyPivot = [];
foreach ($data['involved_companies'] as $ic) {
if (empty($ic['company']['name'])) continue;
$company = Company::firstOrCreate(
['igdb_id' => $ic['company']['id']],
['name' => $ic['company']['name'], 'slug' => \Str::slug($ic['company']['name'])]
);
$role = $ic['developer'] ? 'developer' : ($ic['publisher'] ? 'publisher' : 'other');
$companyPivot[$company->id] = ['role' => $role];
}
$game->companies()->sync($companyPivot);
}
if (!empty($data['screenshots'])) {
GameScreenshot::where('game_id', $game->id)->delete();
foreach ($data['screenshots'] as $i => $shot) {
GameScreenshot::create([
'game_id' => $game->id,
'url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_big'),
'thumb_url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_med'),
'order' => $i,
]);
}
}
}
}
@@ -33,11 +33,11 @@ class GenerateSitemapCommand extends Command
}); });
User::where('is_private', false) User::where('is_private', false)
->select('username', 'updated_at') ->select('slug', 'updated_at')
->chunk(500, function ($users) use ($sitemap) { ->chunk(500, function ($users) use ($sitemap) {
foreach ($users as $user) { foreach ($users as $user) {
$sitemap->add( $sitemap->add(
Url::create("/users/{$user->username}") Url::create("/users/{$user->slug}")
->setLastModificationDate($user->updated_at) ->setLastModificationDate($user->updated_at)
->setPriority(0.5) ->setPriority(0.5)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY) ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
+2 -88
View File
@@ -2,16 +2,13 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Company; use App\Console\Commands\Concerns\UpsertsGames;
use App\Models\Game;
use App\Models\GameScreenshot;
use App\Models\Genre;
use App\Models\Platform;
use App\Services\IgdbService; use App\Services\IgdbService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ImportGamesCommand extends Command class ImportGamesCommand extends Command
{ {
use UpsertsGames;
protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new} {--year=} {--platform=}'; protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new} {--year=} {--platform=}';
protected $description = 'Import games from IGDB into the local database'; protected $description = 'Import games from IGDB into the local database';
@@ -74,87 +71,4 @@ class ImportGamesCommand extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function upsertGame(array $data, IgdbService $igdb): void
{
$igdbId = $data['id'];
$title = $data['name'] ?? 'Unknown';
$releaseDate = isset($data['first_release_date'])
? date('Y-m-d', $data['first_release_date'])
: null;
$releaseYear = $releaseDate ? substr($releaseDate, 0, 4) : null;
$coverUrl = null;
$coverThumbUrl = null;
if (!empty($data['cover']['image_id'])) {
$coverUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_big');
$coverThumbUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_small');
}
$game = Game::updateOrCreate(
['igdb_id' => $igdbId],
[
'title' => $title,
'summary' => $data['summary'] ?? null,
'cover_url' => $coverUrl,
'cover_thumb_url'=> $coverThumbUrl,
'release_date' => $releaseDate,
'release_year' => $releaseYear,
'status' => 'published',
]
);
// Genres
if (!empty($data['genres'])) {
$genreIds = [];
foreach ($data['genres'] as $g) {
$genre = Genre::firstOrCreate(
['igdb_id' => $g['id']],
['name' => $g['name'], 'slug' => \Str::slug($g['name'])]
);
$genreIds[] = $genre->id;
}
$game->genres()->sync($genreIds);
}
// Platforms
if (!empty($data['platforms'])) {
$platformIds = [];
foreach ($data['platforms'] as $p) {
$platform = Platform::firstOrCreate(
['igdb_id' => $p['id']],
['name' => $p['name'], 'slug' => \Str::slug($p['name'])]
);
$platformIds[] = $platform->id;
}
$game->platforms()->sync($platformIds);
}
// Companies (involved_companies)
if (!empty($data['involved_companies'])) {
$companyPivot = [];
foreach ($data['involved_companies'] as $ic) {
if (empty($ic['company']['name'])) continue;
$company = Company::firstOrCreate(
['igdb_id' => $ic['company']['id']],
['name' => $ic['company']['name'], 'slug' => \Str::slug($ic['company']['name'])]
);
$role = $ic['developer'] ? 'developer' : ($ic['publisher'] ? 'publisher' : 'other');
$companyPivot[$company->id] = ['role' => $role];
}
$game->companies()->sync($companyPivot);
}
// Screenshots
if (!empty($data['screenshots'])) {
GameScreenshot::where('game_id', $game->id)->delete();
foreach ($data['screenshots'] as $i => $shot) {
GameScreenshot::create([
'game_id' => $game->id,
'url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_big'),
'thumb_url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_med'),
'order' => $i,
]);
}
}
}
} }
+21
View File
@@ -116,6 +116,27 @@ class IgdbService
"); ");
} }
/**
* Get games for backfill — broader filter than getPopularGames.
*/
public function getPopularGamesForBackfill(int $limit = 500, int $offset = 0, int $minRatings = 5): array
{
return $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date,
genres.id,genres.name,genres.slug,
platforms.id,platforms.name,platforms.slug,platforms.abbreviation,
involved_companies.company.id,involved_companies.company.name,
involved_companies.company.slug,involved_companies.developer,
involved_companies.publisher,
screenshots.image_id,
rating,rating_count;
where rating != null & rating_count > {$minRatings} & cover != null;
sort rating_count desc;
limit {$limit};
offset {$offset};
");
}
/** /**
* Get recently released games. * Get recently released games.
*/ */