build: initialize RetroHub with Laravel, Inertia, Vue, and Horizon

This commit is contained in:
2026-08-10 13:45:18 +02:00
commit 717db65139
218 changed files with 28356 additions and 0 deletions
@@ -0,0 +1,62 @@
<?php
namespace App\Features\Scraper\Controllers;
use App\Features\Consoles\Models\Console;
use App\Features\Games\Models\Game;
use App\Features\Scraper\Data\ScraperResultData;
use App\Features\Scraper\Services\ScraperService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ScraperController extends Controller
{
public function __construct(
private ScraperService $scraper
) {}
public function index(): Response
{
return Inertia::render('Scraper/Index', [
'consoles' => Console::orderBy('name')->get(),
]);
}
public function search(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|min:2',
'console' => 'nullable|string',
]);
$results = $this->scraper->search(
$request->query('query'),
$request->query('console')
)->map(fn ($result) => ScraperResultData::from($result));
return response()->json(['results' => $results]);
}
public function apply(Request $request, string $slug): RedirectResponse
{
$game = Game::where('slug', $slug)->firstOrFail();
$request->validate([
'cover_url' => 'required|url',
]);
$path = $this->scraper->downloadCover(
$request->cover_url,
$game
);
$game->update(['cover_path' => $path]);
return redirect()->route('games.show', $game)
->with('success', 'Cover updated successfully.');
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Features\Scraper\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class ScraperResultData extends Data
{
public function __construct(
public int $id,
public string $title,
public ?string $coverUrl,
public ?string $year,
public string $source,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Features\Scraper\Services;
use Illuminate\Support\Collection;
interface CoverScraperInterface
{
public function search(string $query, ?int $consoleId = null): Collection;
public function getCoverUrl(int $externalId): ?string;
public function getScreenshotUrl(int $externalId): ?string;
public function getImages(int $externalId): array;
}
@@ -0,0 +1,120 @@
<?php
namespace App\Features\Scraper\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class IGDBCoverScraper implements CoverScraperInterface
{
private string $clientId;
private string $clientSecret;
private ?string $accessToken = null;
public function __construct()
{
$this->clientId = config('services.igdb.client_id', '');
$this->clientSecret = config('services.igdb.client_secret', '');
}
public function search(string $query, ?int $consoleId = null): Collection
{
if (empty($this->clientId) || empty($this->clientSecret)) {
return collect();
}
$body = "search \"{$query}\"; fields name,cover.url,platforms,release_dates.year;";
if ($consoleId) {
$body .= " where platforms = [{$consoleId}];";
}
$response = $this->makeRequest('/games', $body);
return collect($response)->map(fn ($game) => [
'id' => $game['id'],
'title' => $game['name'],
'cover_url' => isset($game['cover']['url'])
? str_replace('thumb', 'cover_big', $game['cover']['url'])
: null,
'year' => $game['release_dates'][0]['year'] ?? null,
'source' => 'igdb',
]);
}
public function getCoverUrl(int $externalId): ?string
{
if (empty($this->clientId) || empty($this->clientSecret)) {
return null;
}
$response = $this->makeRequest(
'/covers',
"fields url; where game = {$externalId};"
);
if (! empty($response[0]['url'])) {
return str_replace('thumb', 'cover_big', $response[0]['url']);
}
return null;
}
public function getScreenshotUrl(int $externalId): ?string
{
if (empty($this->clientId) || empty($this->clientSecret)) {
return null;
}
$response = $this->makeRequest(
'/screenshots',
"fields url; where game = {$externalId};"
);
return $response[0]['url'] ?? null;
}
public function getImages(int $externalId): array
{
return array_filter([
'cover' => $this->getCoverUrl($externalId),
'screenshot' => $this->getScreenshotUrl($externalId),
]);
}
private function makeRequest(string $endpoint, string $body): array
{
try {
$this->ensureAccessToken();
$response = Http::withHeaders([
'Client-ID' => $this->clientId,
'Authorization' => "Bearer {$this->accessToken}",
])->timeout(10)->post("https://api.igdb.com/v4{$endpoint}", $body);
if ($response->successful()) {
return $response->json();
}
} catch (\Exception $e) {
// Log error
}
return [];
}
private function ensureAccessToken(): void
{
if ($this->accessToken) {
return;
}
$response = Http::asForm()->post('https://id.twitch.tv/oauth2/token', [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'client_credentials',
]);
$this->accessToken = $response->json('access_token');
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Features\Scraper\Services;
use App\Features\Consoles\Models\Console;
use App\Features\Games\Models\Game;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class ScraperService
{
public function __construct(
private IGDBCoverScraper $igdb,
private TGDBCoverScraper $tgdb
) {}
public function search(string $query, ?string $consoleSlug = null): Collection
{
$console = $consoleSlug
? Console::where('slug', $consoleSlug)->first()
: null;
$consoleId = $console?->igdb_platform_id;
$results = $this->igdb->search($query, $consoleId);
if ($results->isEmpty()) {
$tgdbConsoleId = $console?->tgdb_platform_id;
$results = $this->tgdb->search($query, $tgdbConsoleId);
}
return $results;
}
public function scrapeForGame(Game $game): ?string
{
$coverUrl = null;
if ($game->igdb_id) {
$coverUrl = $this->igdb->getCoverUrl($game->igdb_id);
}
if (! $coverUrl && $game->tgdb_id) {
$coverUrl = $this->tgdb->getCoverUrl($game->tgdb_id);
}
if ($coverUrl) {
return $this->downloadCover($coverUrl, $game);
}
return null;
}
public function downloadCover(string $url, Game $game): string
{
$consoleSlug = $game->console->slug ?? 'unknown';
$filename = "{$game->slug}-cover.jpg";
$path = "covers/{$consoleSlug}/{$filename}";
try {
$contents = Http::timeout(30)->get($url)->body();
Storage::disk('local')->put($path, $contents);
return $path;
} catch (\Exception $e) {
return '';
}
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Features\Scraper\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class TGDBCoverScraper implements CoverScraperInterface
{
private string $apiKey;
private string $baseUrl = 'https://api.thegamesdb.net/v1';
public function __construct()
{
$this->apiKey = config('services.thegamesdb.api_key', '');
}
public function search(string $query, ?int $consoleId = null): Collection
{
if (empty($this->apiKey)) {
return collect();
}
$params = [
'apikey' => $this->apiKey,
'name' => $query,
];
if ($consoleId) {
$params['platform'] = $consoleId;
}
try {
$response = Http::timeout(10)->get("{$this->baseUrl}/Games/ByGameName", $params);
if ($response->successful()) {
$games = $response->json('data.games') ?? [];
return collect($games)->map(fn ($game) => [
'id' => $game['id'],
'title' => $game['game_title'],
'cover_url' => $game['cover'] ?? null,
'year' => isset($game['release_date'])
? date('Y', strtotime($game['release_date']))
: null,
'source' => 'thegamesdb',
]);
}
} catch (\Exception $e) {
// Log error
}
return collect();
}
public function getCoverUrl(int $externalId): ?string
{
if (empty($this->apiKey)) {
return null;
}
try {
$response = Http::timeout(10)->get("{$this->baseUrl}/Games/Images", [
'apikey' => $this->apiKey,
'id' => $externalId,
'images_type' => 'boxart',
]);
if ($response->successful()) {
$images = $response->json('data.images') ?? [];
if (! empty($images[0]['filename'])) {
return "https://cdn.thegamesdb.net/images/original/{$images[0]['filename']}";
}
}
} catch (\Exception $e) {
// Log error
}
return null;
}
public function getScreenshotUrl(int $externalId): ?string
{
if (empty($this->apiKey)) {
return null;
}
try {
$response = Http::timeout(10)->get("{$this->baseUrl}/Games/Images", [
'apikey' => $this->apiKey,
'id' => $externalId,
'images_type' => 'screenshot',
]);
if ($response->successful()) {
$images = $response->json('data.images') ?? [];
if (! empty($images[0]['filename'])) {
return "https://cdn.thegamesdb.net/images/original/{$images[0]['filename']}";
}
}
} catch (\Exception $e) {
// Log error
}
return null;
}
public function getImages(int $externalId): array
{
return array_filter([
'cover' => $this->getCoverUrl($externalId),
'screenshot' => $this->getScreenshotUrl($externalId),
]);
}
}