Files
RetroHub/PROJECT_SPEC.md
T

1300 lines
39 KiB
Markdown

# RetroHub - Project Specificatie
## Visie
RetroHub is een self-hosted retro game bibliotheek die games, covers en saves centraliseert. Het integreert met EmuDeck voor naadloze synchronisatie tussen apparaten, en biedt een DeckyLoader plugin voor directe toegang vanaf de Steam Deck.
## Architectuur
```
┌────────────────────────────────────────────────────────────────┐
│ RETROHUB │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Docker Container │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ │
│ │ │ Laravel 13 │ │ Vue 3 + │ │ PostgreSQL 16 │ │ │
│ │ │ + Inertia │ │ Tailwind 4 │ │ + pgvector │ │ │
│ │ └──────┬─────┘ └────────────┘ └────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼─────┐ ┌────────────┐ ┌────────────────────┐ │ │
│ │ │ WebDAV │ │ Cover │ │ Game File Storage │ │ │
│ │ │ Server │ │ Scraper │ │ (per console) │ │ │
│ │ │ (SabreDAV) │ │ Service │ │ │ │ │
│ │ └──────┬─────┘ └────────────┘ └────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼─────┐ ┌────────────┐ │ │
│ │ │ Redis │ │ Queue │ │ │
│ │ │ (cache) │ │ Worker │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ EmuDeck │ │ DeckyLoader │ │
│ │ (rclone WebDAV) │◄────►│ Plugin (optioneel)│ │
│ └───────────────────┘ └───────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
## Doelgroep
- Eigenaren van een Steam Deck of retro gaming setup
- Gebruikers die EmuDeck gebruiken voor het emuleren van retro games
- Self-hosters die hun game bibliotheek willen beheren
## Kernfunctionaliteit
### 1. Game Bibliotheek
- ROM bestanden uploaden en beheren
- Organisatie per console (NES, SNES, GBA, PS1, PS2, N64, GameCube, Genesis, etc.)
- Automatische metadata Detectie op bestandsnaam
- Handmatig bewerken van game details (titel, beschrijving, genre, jaar, developer, publisher)
- Zoeken en filteren door de gehele bibliotheek
- Bulk upload voor meerdere ROMs tegelijk
### 2. Cover Scraper
Twee-bron systeem met automatische fallback:
**Primaire: IGDB (Internet Game Database)**
- OAuth authenticatie via Twitch developer account
- Zoeken op titel + platform ID
- Cover, screenshot, fanart ophalen
- Rate limit: 4 requests per seconde
- Gratis, vereist Twitch dev account
**Fallback: TheGamesDB**
- API key configuratie
- Zoeken op titel + platform ID
- Cover, screenshot, fanart, logo ophalen
- Rate limit: 1000 requests per maand
- Makkelijker setup dan IGDB
**Workflow:**
1. Game wordt geupload
2. ScraperJob wordt in queue gezet
3. Eerst IGDB proberen
4. Als geen resultaat → TheGamesDB proberen
5. Cover wordt opgeslagen in storage
6. Pad wordt opgeslagen in database
### 3. WebDAV Server (EmuDeck Integratie)
Gebruikmakend van `n3xt0r/laravel-webdav-server` gebaseerd op SabreDAV.
**Functionaliteit:**
- Exposeert Laravel storage disks via WebDAV protocol
- User-geïsoleerde opslag (elke gebruiker heeft eigen pad)
- HTTP Basic Auth authenticatie
- Compatibel met rclone WebDAV client
- Compatibel met macOS Finder, Windows Explorer, Linux file managers
**Mapstructuur:**
```
/webdav/
├── default/ # Game bibliotheek
│ ├── NES/
│ │ ├── Super Mario Bros (1985).nes
│ │ └── The Legend of Zelda (1986).nes
│ ├── SNES/
│ │ └── Super Metroid (1994).sfc
│ ├── GBA/
│ ├── PS1/
│ └── ...
└── saves/ # Save bestanden
├── NES/
│ ├── Super Mario Bros (1985).srm
│ └── ...
├── SNES/
└── ...
```
**EmuDeck Koppeling (stappen voor gebruiker):**
1. Installeer rclone op Steam Deck/Desktop
2. Maak een nieuwe "WebDAV remote" aan
3. Vul RetroHub URL, username en wachtwoord in
4. Configureer EmuDeck CloudSync om deze remote te gebruiken
5. Saves worden automatisch gesynchroniseerd
### 4. Multi-User Ondersteuning
- Gebruikers registratie en authenticatie (Laravel Breeze)
- Elke gebruiker heeft eigen game bibliotheek
- Elke gebruiker heeft eigen saves
- Instellingen per gebruiker
- Admin rol voor beheerders
### 5. DeckyLoader Plugin (Steam Deck)
Optionele plugin voor directe toegang vanaf de Steam Deck.
**Frontend (TypeScript/React):**
- Game browser in Steam overlay
- Cover thumbnails weergeven
- Games starten via EmuDeck
- Sync status indicator
- Settings pagina (RetroHub URL, credentials)
**Backend (Python):**
- API calls naar RetroHub
- Configuratie opslaan in Decky settings directory
- Logging naar Decky log directory
## Technologie Stack
### Backend
| Component | Technologie | Versie |
|-----------|-------------|--------|
| Framework | Laravel | 13.x |
| PHP | PHP | 8.3+ |
| Database | PostgreSQL | 16 |
| Cache | Redis | 7.x |
| Queue | Laravel Horizon | - |
| WebDAV | n3xt0r/laravel-webdav-server | - |
| Authenticatie | Laravel Breeze | - |
| File Storage | Laravel Flysystem | - |
### Frontend
| Component | Technologie | Versie |
|-----------|-------------|--------|
| UI Framework | Vue | 3.x |
| Router | Inertia.js | v3 |
| CSS | Tailwind CSS | v4 |
| Icons | Lucide Vue | - |
| State | Pinia | - |
| Forms | VeeValidate + Zod | - |
| Notifications | Vue Sonner | - |
### DeckyLoader Plugin
| Component | Technologie | Versie |
|-----------|-------------|--------|
| Frontend | TypeScript + React | - |
| UI Library | @decky/ui | - |
| Backend | Python | 3.x |
| HTTP Client | aiohttp | - |
### Infrastructuur
| Component | Technologie | Versie |
|-----------|-------------|--------|
| Container | Docker | - |
| Orchestration | Docker Compose | v3.8 |
| Web Server | Nginx | - |
| PHP Runtime | PHP-FPM | 8.3 |
## Database Schema
### consoles
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| name | string | Console naam (bijv. "Nintendo Entertainment System") |
| slug | string (unique) | URL-vriendelijke naam (bijv. "nes") |
| icon_path | string, nullable | Pad naar console icoon |
| rom_extensions | json | Lijst van ondersteunde bestandsextensies ["nes", "fds"] |
| igdb_platform_id | integer, nullable | Platform ID voor IGDB API |
| tgdb_platform_id | integer, nullable | Platform ID voor TheGamesDB API |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
### games
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| console_id | bigint (FK) | Verwijzing naar console |
| title | string | Game titel |
| slug | string (unique) | URL-vriendelijke titel |
| igdb_id | integer, nullable | IGDB game ID |
| tgdb_id | integer, nullable | TheGamesDB game ID |
| description | text, nullable | Game beschrijving |
| release_year | integer, nullable | Uitgave jaar |
| developer | string, nullable | Ontwikkelaar |
| publisher | string, nullable | Uitgever |
| genre | string, nullable | Genre |
| rating | decimal(3,1), nullable | Beoordeling (0.0 - 10.0) |
| cover_path | string, nullable | Pad naar cover afbeelding |
| thumbnail_path | string, nullable | Pad naar thumbnail |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
### rom_files
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| game_id | bigint (FK) | Verwijzing naar game |
| file_path | string | Pad naar ROM bestand |
| file_name | string | Originele bestandsnaam |
| file_size | bigint | Bestandsgrootte in bytes |
| file_hash | string (unique) | SHA256 hash voor deduplicatie |
| region | string, nullable | Regio (bijv. "US", "EU", "JP") |
| language | string, nullable | Taal |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
### save_files
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| user_id | bigint (FK) | Verwijzing naar gebruiker |
| game_id | bigint (FK) | Verwijzing naar game |
| file_path | string | Pad naar save bestand |
| file_name | string | Bestandsnaam |
| file_size | bigint | Bestandsgrootte in bytes |
| last_synced_at | timestamp, nullable | Laatste synchronisatie |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
### web_dav_accounts
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| user_id | bigint (FK) | Verwijzing naar gebruiker |
| username | string (unique) | WebDAV username |
| password | string | Gehashed wachtwoord |
| is_active | boolean | Account actief |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
### user_settings
| Kolom | Type | Beschrijving |
|-------|------|--------------|
| id | bigint (PK) | Uniek ID |
| user_id | bigint (FK) | Verwijzing naar gebruiker |
| setting_key | string | Settings sleutel |
| setting_value | text, nullable | Settings waarde |
| created_at | timestamp | Aanmaak datum |
| updated_at | timestamp | Laatste update |
## API Endpoints
### Public
```
POST /api/register # Gebruiker registreren
POST /api/login # Inloggen
POST /api/logout # Uitloggen
```
### Games
```
GET /api/consoles # Alle consoles
GET /api/consoles/{slug} # Console details
GET /api/consoles/{slug}/games # Games per console
GET /api/games # Alle games (met filters)
GET /api/games/{slug} # Game details
POST /api/games/upload # ROM uploaden
PUT /api/games/{id} # Game bijwerken
DELETE /api/games/{id} # Game verwijderen
POST /api/games/{id}/scrape # Cover opnieuw scrapen
GET /api/games/{id}/cover # Cover ophalen
GET /api/games/{id}/download # ROM downloaden
```
### Saves
```
GET /api/saves # User saves
POST /api/saves/sync # Save synchroniseren
DELETE /api/saves/{id} # Save verwijderen
```
### Settings
```
GET /api/settings # User settings ophalen
PUT /api/settings # Settings bijwerken
GET /api/webdav/credentials # WebDAV credentials ophalen
POST /api/webdav/credentials # WebDAV credentials aanmaken
DELETE /api/webdav/credentials/{id} # WebDAV credentials verwijderen
```
### Admin
```
GET /api/admin/users # Alle gebruikers
PUT /api/admin/users/{id} # Gebruiker bijwerken
DELETE /api/admin/users/{id} # Gebruiker verwijderen
GET /api/admin/consoles # Alle consoles (beheer)
POST /api/admin/consoles # Console toevoegen
PUT /api/admin/consoles/{id} # Console bijwerken
DELETE /api/admin/consoles/{id} # Console verwijderen
```
## Web Routes (Inertia)
| Route | Component | Beschrijving |
|-------|-----------|--------------|
| `GET /` | Dashboard | Overzicht bibliotheek |
| `GET /login` | Auth/Login | Inlog pagina |
| `GET /register` | Auth/Register | Registratie pagina |
| `GET /consoles` | Consoles/Index | Alle consoles |
| `GET /consoles/{slug}` | Consoles/Show | Games per console |
| `GET /games/{slug}` | Games/Show | Game details |
| `GET /games/{slug}/edit` | Games/Edit | Game bewerken |
| `GET /upload` | Upload/Index | ROM uploaden |
| `GET /upload/bulk` | Upload/Bulk | Bulk upload |
| `GET /scraper` | Scraper/Index | Handmatig covers zoeken |
| `GET /settings` | Settings/Index | Account instellingen |
| `GET /settings/webdav` | Settings/WebDav | WebDAV instructies |
| `GET /admin` | Admin/Dashboard | Admin panel |
| `GET /admin/users` | Admin/Users | Gebruikers beheer |
| `GET /admin/consoles` | Admin/Consoles | Console beheer |
## Docker Configuratie
### Dockerfile
```dockerfile
FROM php:8.3-fpm-alpine
# System dependencies
RUN apk add --no-cache \
nginx \
supervisor \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
oniguruma-dev \
libxml2-dev \
zip \
unzip \
git
# PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \
pdo_mysql \
mbstring \
exif \
bcmath \
gd \
zip \
intl \
opcache
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# PHP config
COPY docker/php.ini /usr/local/etc/php/conf.d/retrohub.ini
# Application
WORKDIR /var/www/retrohub
COPY . .
# Install dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
RUN npm ci && npm run build
# Permissions
RUN chown -R www-data:www-data /var/www/retrohub \
&& chmod -R 755 /var/www/retrohub/storage \
&& chmod -R 755 /var/www/retrohub/bootstrap/cache
# Supervisor config
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
```
### docker-compose.yml
```yaml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: retrohub-app
restart: unless-stopped
ports:
- "8080:80"
volumes:
- retrohub-roms:/var/www/retrohub/storage/app/roms
- retrohub-saves:/var/www/retrohub/storage/app/saves
- retrohub-covers:/var/www/retrohub/storage/app/covers
environment:
- APP_URL=${APP_URL:-http://localhost:8080}
- APP_KEY=${APP_KEY}
- DB_CONNECTION=pgsql
- DB_HOST=db
- DB_PORT=5432
- DB_DATABASE=retrohub
- DB_USERNAME=retrohub
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- CACHE_DRIVER=redis
- SESSION_DRIVER=redis
- QUEUE_CONNECTION=redis
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
- TGDB_API_KEY=${TGDB_API_KEY}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: pgvector/pgvector:pg16
container_name: retrohub-db
restart: unless-stopped
volumes:
- retrohub-db:/var/lib/postgresql/data
environment:
- POSTGRES_DB=retrohub
- POSTGRES_USER=retrohub
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U retrohub"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: retrohub-redis
restart: unless-stopped
volumes:
- retrohub-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
worker:
build:
context: .
dockerfile: Dockerfile
container_name: retrohub-worker
restart: unless-stopped
command: php artisan horizon
volumes:
- retrohub-roms:/var/www/retrohub/storage/app/roms
- retrohub-saves:/var/www/retrohub/storage/app/saves
- retrohub-covers:/var/www/retrohub/storage/app/covers
environment:
- APP_URL=${APP_URL:-http://localhost:8080}
- APP_KEY=${APP_KEY}
- DB_CONNECTION=pgsql
- DB_HOST=db
- DB_PORT=5432
- DB_DATABASE=retrohub
- DB_USERNAME=retrohub
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- CACHE_DRIVER=redis
- SESSION_DRIVER=redis
- QUEUE_CONNECTION=redis
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
- TGDB_API_KEY=${TGDB_API_KEY}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
retrohub-db:
retrohub-redis:
retrohub-roms:
retrohub-saves:
retrohub-covers:
```
## Cover Scraper Implementatie
### Interface
```php
namespace App\Services\CoverScraper;
use Illuminate\Support\Collection;
interface CoverScraperInterface
{
/**
* Zoek games op titel en optioneel console
*/
public function search(string $query, ?int $consoleId = null): Collection;
/**
* Haal cover URL op voor een game
*/
public function getCoverUrl(int $externalId): ?string;
/**
* Haal screenshot URL op voor een game
*/
public function getScreenshotUrl(int $externalId): ?string;
/**
* Haal alle beschikbare afbeeldingen op
*/
public function getImages(int $externalId): array;
```
### Platform Mapping
```php
// config/platforms.php
return [
'platforms' => [
'nes' => [
'name' => 'Nintendo Entertainment System',
'igdb_id' => 18,
'tgdb_id' => 4951,
'extensions' => ['nes', 'fds'],
],
'snes' => [
'name' => 'Super Nintendo Entertainment System',
'igdb_id' => 19,
'tgdb_id' => 4949,
'extensions' => ['sfc', 'smc'],
],
'n64' => [
'name' => 'Nintendo 64',
'igdb_id' => 4,
'tgdb_id' => 4948,
'extensions' => ['n64', 'v64', 'z64'],
],
'gamecube' => [
'name' => 'Nintendo GameCube',
'igdb_id' => 21,
'tgdb_id' => 4947,
'extensions' => ['iso', 'gcm'],
],
'gba' => [
'name' => 'Game Boy Advance',
'igdb_id' => 33,
'tgdb_id' => 4953,
'extensions' => ['gba'],
],
'nds' => [
'name' => 'Nintendo DS',
'igdb_id' => 33,
'tgdb_id' => 4954,
'extensions' => ['nds'],
],
'genesis' => [
'name' => 'Sega Genesis / Mega Drive',
'igdb_id' => 29,
'tgdb_id' => 4944,
'extensions' => ['md', 'bin', 'gen'],
],
'saturn' => [
'name' => 'Sega Saturn',
'igdb_id' => 30,
'tgdb_id' => 4945,
'extensions' => ['iso', 'bin', 'cue'],
],
'dreamcast' => [
'name' => 'Sega Dreamcast',
'igdb_id' => 31,
'tgdb_id' => 4946,
'extensions' => ['gdi', 'cdi', 'iso'],
],
'ps1' => [
'name' => 'Sony PlayStation',
'igdb_id' => 7,
'tgdb_id' => 4956,
'extensions' => ['iso', 'bin', 'cue'],
],
'ps2' => [
'name' => 'Sony PlayStation 2',
'igdb_id' => 8,
'tgdb_id' => 4957,
'extensions' => ['iso', 'bin', 'cue'],
],
'psp' => [
'name' => 'Sony PlayStation Portable',
'igdb_id' => 38,
'tgdb_id' => 4958,
'extensions' => ['iso', 'cso'],
],
],
];
```
### IGDB Scraper
```php
namespace App\Services\CoverScraper;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Collection;
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
{
$platformConfig = $consoleId
? config("platforms.platforms.{$consoleId}")
: null;
$body = "search \"{$query}\"; fields name,cover.url,platforms,release_dates.year;";
if ($platformConfig) {
$body .= " where platforms = [{$platformConfig['igdb_id']}];";
}
$response = $this->makeRequest('/games', $body);
return collect($response)->map(fn($game) => [
'id' => $game['id'],
'title' => $game['name'],
'cover_url' => $game['cover']['url'] ?? null,
'year' => $game['release_dates'][0]['year'] ?? null,
]);
}
public function getCoverUrl(int $externalId): ?string
{
$response = $this->makeRequest(
'/covers',
"fields url; where game = {$externalId};"
);
return $response[0]['url'] ?? null;
}
private function makeRequest(string $endpoint, string $body): array
{
$this->ensureAccessToken();
$response = Http::withHeaders([
'Client-ID' => $this->clientId,
'Authorization' => "Bearer {$this->accessToken}",
])->post("https://api.igdb.com/v4{$endpoint}", $body);
return $response->json();
}
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');
}
}
```
### TheGamesDB Scraper (Fallback)
```php
namespace App\Services\CoverScraper;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Collection;
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
{
$platformId = $consoleId
? config("platforms.platforms.{$consoleId}.tgdb_id")
: null;
$params = [
'apikey' => $this->apiKey,
'name' => $query,
];
if ($platformId) {
$params['platform'] = $platformId;
}
$response = Http::get("{$this->baseUrl}/Games/ByGameName", $params);
return collect($response->json('data.games') ?? [])->map(fn($game) => [
'id' => $game['id'],
'title' => $game['game_title'],
'cover_url' => $game['cover'] ?? null,
'year' => $game['release_date'] ? date('Y', strtotime($game['release_date'])) : null,
]);
}
public function getCoverUrl(int $externalId): ?string
{
$response = Http::get("{$this->baseUrl}/Games/Images", [
'apikey' => $this->apiKey,
'id' => $externalId,
'images_type' => 'boxart',
]);
$images = $response->json('data.images') ?? [];
return $images[0]['filename'] ?? null;
}
}
```
### Scraper Service (Orchestrator)
```php
namespace App\Services\CoverScraper;
use App\Models\Game;
use App\Models\Console;
use Illuminate\Support\Collection;
class ScraperService
{
private IGDBCoverScraper $igdb;
private TGDBCoverScraper $tgdb;
public function __construct(
IGDBCoverScraper $igdb,
TGDBCoverScraper $tgdb
) {
$this->igdb = $igdb;
$this->tgdb = $tgdb;
}
public function search(string $query, ?string $consoleSlug = null): Collection
{
$consoleId = $consoleSlug
? Console::where('slug', $consoleSlug)->first()?->id
: null;
// Probeer IGDB eerst
$results = $this->igdb->search($query, $consoleId);
// Fallback naar TheGamesDB als geen resultaat
if ($results->isEmpty()) {
$results = $this->tgdb->search($query, $consoleId);
}
return $results;
}
public function scrapeForGame(Game $game): ?string
{
$coverUrl = $this->igdb->getCoverUrl($game->igdb_id);
if (!$coverUrl && $game->tgdb_id) {
$coverUrl = $this->tgdb->getCoverUrl($game->tgdb_id);
}
if ($coverUrl) {
$path = $this->downloadCover($coverUrl, $game);
$game->update(['cover_path' => $path]);
return $path;
}
return null;
}
private function downloadCover(string $url, Game $game): string
{
$filename = "{$game->slug}-cover.jpg";
$path = "covers/{$game->console->slug}/{$filename}";
$contents = Http::get($url)->body();
Storage::disk('local')->put($path, $contents);
return $path;
}
}
```
### Queue Job
```php
namespace App\Jobs;
use App\Models\Game;
use App\Services\CoverScraper\ScraperService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ScrapeCoverJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private Game $game
) {}
public function handle(ScraperService $scraper): void
{
$scraper->scrapeForGame($this->game);
}
}
```
## DeckyLoader Plugin
### plugin.json
```json
{
"name": "RetroHub",
"author": "RetroHub",
"plugin_version": "1.0.0",
"description": "Access your RetroHub game library from Steam Deck",
"image": "",
"image_webp": "",
"icon": "",
"badge": "",
"tags": ["library", "retro", "gaming"],
"type": "plugin"
}
```
### Backend (Python)
```python
import decky
import aiohttp
import json
import os
class Plugin:
async def _main(self):
self.settings = decky.migrate_settings()
self.base_url = self.settings.get("retrohub_url", "")
self.api_token = self.settings.get("api_token", "")
async def _unload(self):
pass
def _get_headers(self):
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
}
async def get_consoles(self):
if not self.base_url:
return {"error": "RetroHub URL not configured"}
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.base_url}/api/consoles",
headers=self._get_headers()
) as resp:
return await resp.json()
async def get_games(self, console_slug: str = None, page: int = 1):
if not self.base_url:
return {"error": "RetroHub URL not configured"}
params = {"page": page}
if console_slug:
params["console"] = console_slug
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.base_url}/api/games",
headers=self._get_headers(),
params=params
) as resp:
return await resp.json()
async def get_sync_status(self):
if not self.base_url:
return {"error": "RetroHub URL not configured"}
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.base_url}/api/saves/sync-status",
headers=self._get_headers()
) as resp:
return await resp.json()
async def set_config(self, url: str, token: str):
self.base_url = url
self.api_token = token
self.settings["retrohub_url"] = url
self.settings["api_token"] = token
return True
async def get_config(self):
return {
"retrohub_url": self.base_url,
"has_token": bool(self.api_token)
}
```
### Frontend (TypeScript/React)
```tsx
import { definePlugin } from "@decky/api";
import { PanelSection, Spinner, ButtonItem } from "@decky/ui";
import { FC, useState, useEffect, useCallback } from "react";
interface Game {
id: number;
title: string;
slug: string;
cover_path: string | null;
console: {
name: string;
slug: string;
};
}
const GameBrowser: FC = () => {
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [selectedConsole, setSelectedConsole] = useState<string | null>(null);
const fetchGames = useCallback(async () => {
setLoading(true);
try {
const result = await fetch(
`/api/games${selectedConsole ? `?console=${selectedConsole}` : ""}`
);
const data = await result.json();
setGames(data.data || []);
} catch (error) {
console.error("Failed to fetch games:", error);
}
setLoading(false);
}, [selectedConsole]);
useEffect(() => {
fetchGames();
}, [fetchGames]);
if (loading) return <Spinner />;
return (
<PanelSection title="RetroHub Library">
{games.map((game) => (
<div key={game.id} className="game-card">
<img
src={game.cover_path || "/placeholder.png"}
alt={game.title}
/>
<span>{game.title}</span>
<span>{game.console.name}</span>
</div>
))}
</PanelSection>
);
};
const SyncStatus: FC = () => {
const [status, setStatus] = useState<any>(null);
useEffect(() => {
const fetchStatus = async () => {
const result = await fetch("/api/saves/sync-status");
const data = await result.json();
setStatus(data);
};
fetchStatus();
}, []);
return (
<PanelSection title="Sync Status">
{status ? (
<div>
<span>Last sync: {status.last_sync || "Never"}</span>
<span>Status: {status.status || "Unknown"}</span>
</div>
) : (
<Spinner />
)}
</PanelSection>
);
};
export default definePlugin({
name: "RetroHub",
description: "Access your RetroHub game library",
main: (
<>
<GameBrowser />
<SyncStatus />
</>
),
});
```
## EmuDeck Integratie Handleiding
### Voor de gebruiker:
1. **RetroHub installeren**
```bash
git clone https://github.com/jouw-username/retrohub.git
cd retrohub
cp .env.example .env
# Vul .env aan met je instellingen
docker compose up -d
```
2. **rclone configureren op Steam Deck/Desktop**
```bash
# Installeer rclone
curl https://rclone.org/install.sh | sudo bash
# Configureer remote
rclone config
# Kies: New remote → Naam: retrohub → Type: webdav
# URL: http://JOUW-RETROHUB-ADRES/webdav/default
# Vendor: other
# Username: jouw-gebruikersnaam
# Password: jouw-wachtwoord
```
3. **EmuDeck CloudSync configureren**
- Open EmuDeck
- Ga naar Settings → CloudSync
- Kies "Custom WebDAV"
- Vul de rclone remote gegevens in
- EmuDeck synchroniseert nu saves naar RetroHub
4. **ROMs synchroniseren**
- Via WebDAV: Mount de WebDAV remote en kopieer ROMs
- Via Web UI: Upload ROMs via de browser
- Via command line: `rclone copy /pad/naar/roms retrohub:/NES/`
## Development Omgeving
### Snel starten
```bash
# Clone repository
git clone https://github.com/jouw-username/retrohub.git
cd retrohub
# Environment bestand
cp .env.example .env
# Start development
docker compose -f docker-compose.dev.yml up -d
# Installeer dependencies
docker compose exec app composer install
docker compose exec app npm install
# Database migraties
docker compose exec app php artisan migrate --seed
# Genereer API keys voor cover scrapers
# Voeg toe aan .env:
# IGDB_CLIENT_ID=...
# IGDB_CLIENT_SECRET=...
# TGDB_API_KEY=...
# Start development servers
docker compose exec app composer run dev
```
### Development Docker Compose
```yaml
# docker-compose.dev.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
container_name: retrohub-dev-app
ports:
- "8000:8000"
- "5173:5173"
volumes:
- .:/var/www/retrohub
environment:
- APP_ENV=local
- APP_DEBUG=true
- VITE_DEV_SERVER_HOST=0.0.0.0
command: php artisan serve --host=0.0.0.0 --port=8000
# ... (db, redis containers hetzelfde als productie)
```
## Project Structuur
```
retrohub/
├── app/
│ ├── Console/
│ │ └── Commands/
│ │ ├── ScanRomsCommand.php
│ │ └── CreateWebDavAccountCommand.php
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Api/
│ │ │ │ ├── GameController.php
│ │ │ │ ├── ConsoleController.php
│ │ │ │ ├── SaveController.php
│ │ │ │ └── SettingsController.php
│ │ │ ├── Web/
│ │ │ │ ├── DashboardController.php
│ │ │ │ ├── GameController.php
│ │ │ │ ├── ConsoleController.php
│ │ │ │ ├── UploadController.php
│ │ │ │ ├── ScraperController.php
│ │ │ │ └── SettingsController.php
│ │ │ └── Admin/
│ │ │ ├── DashboardController.php
│ │ │ ├── UserController.php
│ │ │ └── ConsoleController.php
│ │ ├── Middleware/
│ │ └── Requests/
│ ├── Models/
│ │ ├── Game.php
│ │ ├── Console.php
│ │ ├── RomFile.php
│ │ ├── SaveFile.php
│ │ ├── WebDavAccount.php
│ │ └── UserSetting.php
│ ├── Services/
│ │ └── CoverScraper/
│ │ ├── CoverScraperInterface.php
│ │ ├── IGDBCoverScraper.php
│ │ ├── TGDBCoverScraper.php
│ │ └── ScraperService.php
│ └── Jobs/
│ └── ScrapeCoverJob.php
├── config/
│ ├── platforms.php
│ └── webdav-server.php
├── database/
│ ├── migrations/
│ └── seeders/
├── docker/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── nginx.conf
│ ├── php.ini
│ └── supervisord.conf
├── resources/
│ ├── css/
│ │ └── app.css
│ ├── js/
│ │ ├── app.ts
│ │ ├── Pages/
│ │ │ ├── Dashboard/
│ │ │ ├── Games/
│ │ │ ├── Consoles/
│ │ │ ├── Upload/
│ │ │ ├── Scraper/
│ │ │ ├── Settings/
│ │ │ └── Admin/
│ │ ├── Components/
│ │ │ ├── GameCard.vue
│ │ │ ├── ConsoleGrid.vue
│ │ │ ├── CoverUpload.vue
│ │ │ ├── SearchBar.vue
│ │ │ └── FilterPanel.vue
│ │ └── Layouts/
│ │ └── AppLayout.vue
│ └── views/
│ └── app.blade.php
├── routes/
│ ├── web.php
│ └── api.php
├── decky-plugin/
│ ├── frontend/
│ │ ├── src/
│ │ │ ├── index.tsx
│ │ │ ├── GameBrowser.tsx
│ │ │ ├── SyncStatus.tsx
│ │ │ └── Settings.tsx
│ │ └── package.json
│ ├── backend/
│ │ ├── src/
│ │ │ └── index.py
│ │ └── requirements.txt
│ └── plugin.json
├── docker-compose.yml
├── docker-compose.dev.yml
├── .env.example
├── PROJECT_SPEC.md
└── README.md
```
## Development Fases
### Fase 1: Fundament (Week 1-2)
- [ ] Docker setup (Dockerfile, docker-compose)
- [ ] Laravel 13 project scaffolding
- [ ] Database migraties en models
- [ ] Authenticatie (Laravel Breeze)
- [ ] Basis routing en layouts
### Fase 2: Game Bibliotheek (Week 3-4)
- [ ] ROM upload functionaliteit
- [ ] Bestandsvalidatie (extensie, grootte)
- [ ] Game CRUD operaties
- [ ] Console beheer
- [ ] Basis zoeken en filteren
### Fase 3: Cover Scraper (Week 5-6)
- [ ] IGDB integratie
- [ ] TheGamesDB integratie
- [ ] Scraper service orchestrator
- [ ] Queue jobs voor asynchrone scraping
- [ ] Handmatig scraper interface
### Fase 4: WebDAV Server (Week 7-8)
- [ ] Laravel WebDAV server package installatie
- [ ] WebDAV authenticatie integratie
- [ ] Storage space configuratie
- [ ] User-geïsoleerde paden
- [ ] EmuDeck koppeling testen
### Fase 5: UI Verfijning (Week 9-10)
- [ ] Game card componenten
- [ ] Console grid weergave
- [ ] Cover upload interface
- [ ] Bulk upload functionaliteit
- [ ] Settings pagina's
### Fase 6: DeckyLoader Plugin (Week 11-12)
- [ ] Plugin scaffolding
- [ ] Frontend componenten
- [ ] Python backend
- [ ] API integratie
- [ ] Testing op Steam Deck
### Fase 7: Polish & Documentation (Week 13-14)
- [ ] Foutafhandeling verbeteren
- [ ] Performance optimalisatie
- [ ] README schrijven
- [ ] EmuDeck handleiding
- [ ] Docker Hub publicatie
## Extras / Toekomstige Features
- **AI Metadata Generation**: Gebruik Laravel AI SDK omautomatisch beschrijvingen te genereren op basis van game titel
- **Save State Management**: Visual save state browser in web UI
- **Game Sessions Tracking**: Houd bij hoe lang je elk spel speelt
- **Multiplayer Support**: Deel je bibliotheek met vrienden
- **In-Browser Emulation**: Speel games direct in de browser via WebAssembly emulators
- **ROM Verification**: Controleer of ROMs geldig zijn (no-intro checks)
- **Automatic Console Detection**: Detecteer console type op basis van ROM bestandspatronen