Files
RetroHub/PROJECT_SPEC.md

39 KiB

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

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

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

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

// 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

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)

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)

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

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

{
    "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)

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)

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

    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

    # 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

# 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

# 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