1300 lines
39 KiB
Markdown
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
|