Add PHP Media API scaffold and Docker configs

Initial project scaffold for a PHP Media API including routing, controllers, models and services under api/ (Router, Media/Cast/Image/Settings controllers, models, database/bootstrap files and automatic docs service). Adds Docker support (Dockerfile, docker-compose.yml, DOCKER_README.md, php-custom.ini), .htaccess for pretty URLs, API documentation and example payloads (API_EXAMPLES.md, api/README.md, api_examples/*.json), image handling service and logging, plus a comprehensive .gitignore. This commit provides a runnable development environment and example requests to get the API up and tested quickly.
This commit is contained in:
Lars Behrends
2026-04-12 00:46:30 +02:00
commit 66f69bc90d
54 changed files with 6035 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Composer
/vendor/
composer.lock
# Environment
.env
.env.local
.env.production
# Logs
*.log
logs/
# Cache
/cache/
/tmp/
/temp/
# Database
*.sqlite
*.db
# Node (if applicable)
node_modules/
npm-debug.log
yarn-error.log
# Docker
*.pid
*.seed
*.pid.lock
# Build artifacts
/dist/
/build/
*.min.js
*.min.css
# Uploads
/uploads/
/storage/app/public/*
!/storage/app/public/.gitkeep
*/public/images/*
/api/public/images

1035
API_EXAMPLES.md Normal file

File diff suppressed because it is too large Load Diff

87
DOCKER_README.md Normal file
View File

@@ -0,0 +1,87 @@
# Docker Setup für Kyoo Backend
Dieses Projekt wurde für die Ausführung in einer Docker-Umgebung mit PHP, MariaDB und phpMyAdmin konfiguriert.
## Voraussetzungen
- Docker installiert
- Docker Compose installiert
## Services
Die Docker Compose-Konfiguration enthält folgende Services:
- **PHP 8.2 mit Apache** (Port 8080) - Webserver für die API
- **MariaDB 10.11** (Port 3306) - Datenbank
- **phpMyAdmin** (Port 8081) - Datenbank-Verwaltungsoberfläche
## Datenbank-Zugangsdaten
- **Host:** mariadb (innerhalb Docker) oder localhost (von außen)
- **Datenbank:** kyoo
- **Benutzer:** kyoo_user
- **Passwort:** kyoo_password
- **Root-Passwort:** root_password
## Starten der Umgebung
```bash
docker-compose up -d --build
```
## Stoppen der Umgebung
```bash
docker-compose down
```
## Logs anzeigen
```bash
# Alle Logs
docker-compose logs
# Logs für einen bestimmten Service
docker-compose logs php
docker-compose logs mariadb
docker-compose logs phpmyadmin
```
## Zugriff
- **API:** http://localhost:8080/api
- **phpMyAdmin:** http://localhost:8081
## Datenbank-Reset
Um die Datenbank komplett zurückzusetzen:
```bash
docker-compose down -v
docker-compose up -d --build
```
**Achtung:** Dies löscht alle Datenbankdaten!
## Entwicklung
Die API-Dateien werden als Volume gemountet, sodass Änderungen sofort wirksam werden. Ein Neustart ist nicht notwendig.
## Troubleshooting
### PHP-Container startet nicht
```bash
docker-compose logs php
```
### Datenbank-Verbindungsfehler
Stelle sicher, dass der MariaDB-Container läuft:
```bash
docker-compose ps
```
### Tabellen werden nicht erstellt
Die Tabellen werden automatisch beim ersten API-Aufruf erstellt. Wenn Probleme auftreten, überprüfe die Logs:
```bash
docker-compose logs php
```

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM php:8.2-apache
# Installiere erforderliche PHP-Erweiterungen für MariaDB
RUN docker-php-ext-install pdo pdo_mysql mysqli
# Aktiviere Apache mod_rewrite
RUN a2enmod rewrite
# Setze Arbeitsverzeichnis
WORKDIR /var/www/html
# Kopiere PHP-Konfiguration
COPY php-custom.ini /usr/local/etc/php/conf.d/custom.ini

4
api/.htaccess Normal file
View File

@@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]

472
api/README.md Normal file
View File

@@ -0,0 +1,472 @@
# PHP Media API
Eine schnelle und zuverlässige PHP-API für die Verwaltung von Medieninhalten mit vollständigen CRUD-Operationen.
## Architektur
Die API ist in MVC-ähnliche Struktur aufgeteilt für bessere Wartbarkeit und Erweiterbarkeit:
- **index.php** (35 Zeilen) - Entry Point und Request-Handling
- **Router.php** - Routing-Logik
- **controllers/** - Controller für HTTP-Requests
- MediaController.php
- CastController.php
- **models/** - Datenbank-Modelle
- BaseModel.php - Abstrakte Basisklasse mit CRUD-Methoden
- Media.php - Media Model mit Relationen
- Cast.php - Cast Model mit Filmographie
- AdultCast.php - Erweitertes Cast Model für Adult-Actors
- MediaType.php - Abstrakte Basisklasse für Medientypen
- Movie.php - Movie-spezifische Logik
- Series.php - Series-spezifische Logik
- Music.php - Music-spezifische Logik
- Game.php - Game-spezifische Logik
- Console.php - Console-spezifische Logik
- Adult.php - Adult-spezifische Logik
- **database.php** - Datenbankverbindung und Tabellen-Erstellung
- **config.php** - Konfiguration
- **services/** - Services für Hilfsfunktionen
- DocumentationService.php - Automatische API-Dokumentation
## Installation
1. Stelle sicher, dass PHP und SQLite installiert sind
2. Die Datenbank wird automatisch beim ersten Aufruf erstellt
3. Konfiguriere deinen Webserver (Apache/nginx) so, dass er auf den `api`-Ordner zeigt
## Automatische API-Dokumentation
Die API verfügt über eine automatische Dokumentation, die sich dynamisch aus den Controllern und Modellen generiert:
- `GET /api/docs` - Vollständige API-Dokumentation als JSON
Die Dokumentation scannt alle Controller und Modelle und extrahiert:
- Alle verfügbaren Endpunkte
- HTTP-Methoden
- Parameter und deren Typen
- Rückgabewerte
- Beschreibungen aus PHPDoc-Kommentaren
**Erweiterung der Dokumentation:**
Füge einfach PHPDoc-Kommentare zu deinen Controller-Methoden hinzu:
```php
/**
* Create a new media item
* @param array $data Media data
* @return array Created media ID
*/
private function create() {
// ...
}
```
Die Dokumentation wird automatisch beim nächsten Aufruf aktualisiert.
## Endpunkte
### Media-Endpunkte
- `GET /api/media` - Alle Medien abrufen (mit Filterung und Pagination)
- `GET /api/media/:id` - Ein spezifisches Medium abrufen
- `POST /api/media` - Neues Medium erstellen
- `PUT /api/media/:id` - Medium aktualisieren
- `DELETE /api/media/:id` - Medium löschen
### Episoden-Endpunkte (für Series)
- `GET /api/media/:id/episodes` - Alle Episoden einer Serie abrufen
- `GET /api/media/:id/episodes?season=1` - Episoden einer bestimmten Staffel abrufen
- `GET /api/media/:id/episodes/:episodeId` - Einzelne Episode abrufen
- `POST /api/media/:id/episodes` - Neue Episode zur Serie hinzufügen
- `PUT /api/media/:id/episodes/:episodeId` - Episode aktualisieren
- `DELETE /api/media/:id/episodes/:episodeId` - Episode löschen
### Tracks-Endpunkte (für Music/Alben)
- `GET /api/media/:id/tracks` - Alle Tracks eines Albums abrufen
- `GET /api/media/:id/tracks/:trackId` - Einzelnen Track abrufen
- `POST /api/media/:id/tracks` - Neuen Track zum Album hinzufügen
- `PUT /api/media/:id/tracks/:trackId` - Track aktualisieren
- `DELETE /api/media/:id/tracks/:trackId` - Track löschen
### Cast/Staff-Endpunkte
- `GET /api/cast` - Alle Cast-Mitglieder abrufen
- `GET /api/cast/:id` - Ein spezifisches Cast-Mitglied abrufen (mit Filmographie)
- `GET /api/cast/:id/media` - Alle Medien eines Cast-Mitglieds abrufen
- `POST /api/cast` - Neues Cast-Mitglied erstellen (Stammdaten)
- `PUT /api/cast/:id` - Cast-Mitglied aktualisieren
- `DELETE /api/cast/:id` - Cast-Mitglied löschen
### Adult-Actor Endpunkte (erweiterte Cast-Infos)
- `GET /api/cast/adult` - Alle Adult-Actors mit spezifischen Infos abrufen
- `GET /api/cast/adult?ethnicity=Caucasian&hair_color=Blonde` - Adult-Actors filtern
- `GET /api/cast/:id/adult` - Adult-Actor mit spezifischen Infos abrufen
- `POST /api/cast/adult` - Neuen Adult-Actor mit spezifischen Infos erstellen
- `PUT /api/cast/:id/adult` - Adult-Actor und spezifische Infos aktualisieren
- `DELETE /api/cast/:id/adult` - Spezifische Adult-Infos löschen
## Query-Parameter
### Media-Filter
- `category` - Filter nach Kategorie (Movies, Anime, Music, etc.)
- `type` - Filter nach Typ (Movie, TV, Album, etc.)
- `search` - Suche in Titel und Beschreibung
- `page` - Seitenzahl (Standard: 1)
- `limit` - Ergebnisse pro Seite (Standard: 20)
### Cast-Filter
- `search` - Suche nach Name
- `page` - Seitenzahl
- `limit` - Ergebnisse pro Seite
## Beispiele
### Neues Medium erstellen
```bash
curl -X POST http://localhost/api/media \
-H "Content-Type: application/json" \
-d '{
"title": "Inception",
"year": "2010",
"category": "Movies",
"type": "Movie",
"description": "Ein Dieb...",
"rating": 8.8,
"genres": ["Sci-Fi", "Action"],
"director": "Christopher Nolan",
"staff": [
{
"id": 1,
"role": "Actor",
"characterName": "Cobb"
},
{
"name": "Marion Cotillard",
"role": "Actor",
"characterName": "Mal",
"photo": "https://example.com/photo.jpg"
}
]
}'
```
### Neues Cast-Mitglied erstellen
```bash
curl -X POST http://localhost/api/cast \
-H "Content-Type: application/json" \
-d '{
"name": "Leonardo DiCaprio",
"photo": "https://example.com/photo.jpg",
"bio": "Amerikanischer Schauspieler",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles",
"occupations": ["Actor", "Producer"]
}'
```
### Alle Medien eines Cast-Mitglieds abrufen
```bash
curl http://localhost/api/cast/1/media
```
### Serie mit Episoden erstellen
```bash
curl -X POST http://localhost/api/media \
-H "Content-Type: application/json" \
-d '{
"title": "Breaking Bad",
"year": "2008",
"category": "Movies",
"type": "TV",
"description": "Ein Chemielehrer...",
"genres": ["Drama", "Crime"],
"episodes": [
{
"season": 1,
"episode_number": 1,
"title": "Pilot",
"description": "Erste Episode",
"air_date": "2008-01-20",
"duration": 58
},
{
"season": 1,
"episode_number": 2,
"title": "Cat's in the Bag...",
"duration": 48
}
]
}'
```
### Episode zu einer Serie hinzufügen
```bash
curl -X POST http://localhost/api/media/1/episodes \
-H "Content-Type: application/json" \
-d '{
"season": 2,
"episode_number": 1,
"title": "Seven Thirty-Seven",
"duration": 47
}'
```
### Episoden einer Staffel abrufen
```bash
curl "http://localhost/api/media/1/episodes?season=1"
```
### Album mit Tracklist erstellen
```bash
curl -X POST http://localhost/api/media \
-H "Content-Type: application/json" \
-d '{
"title": "Dark Side of the Moon",
"year": "1973",
"category": "Music",
"type": "Album",
"artist": "Pink Floyd",
"genres": ["Progressive Rock"],
"tracks": [
{
"track_number": 1,
"title": "Speak to Me",
"duration": 90
},
{
"track_number": 2,
"title": "Breathe",
"duration": 274
}
]
}'
```
### Track zu einem Album hinzufügen
```bash
curl -X POST http://localhost/api/media/2/tracks \
-H "Content-Type: application/json" \
-d '{
"track_number": 3,
"title": "On the Run",
"duration": 225
}'
```
### Adult-Actor mit spezifischen Infos erstellen
```bash
curl -X POST http://localhost/api/cast/adult \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"photo": "https://example.com/photo.jpg",
"bio": "Adult actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles",
"occupations": ["Adult Actress", "Model"],
"adult_specifics": {
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": 170,
"weight": 55,
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian",
"tattoos": "None",
"piercings": "Ears",
"measurements": "34D-24-34",
"shoe_size": "38"
}
}'
```
### Adult-Actors nach Ethnie filtern
```bash
curl "http://localhost/api/cast/adult?ethnicity=Caucasian&hair_color=Blonde"
```
### Medien abrufen mit Filter
```bash
curl "http://localhost/api/media?category=Movies&search=inception"
```
### Medium aktualisieren
```bash
curl -X PUT http://localhost/api/media/1 \
-H "Content-Type: application/json" \
-d '{"rating": 9.0}'
```
### Medium löschen
```bash
curl -X DELETE http://localhost/api/media/1
```
## Datenstruktur
### Media-Objekt
```json
{
"id": 1,
"title": "Titel",
"year": "2024",
"poster": "URL",
"banner": "URL",
"description": "Beschreibung",
"rating": 8.5,
"category": "Movies",
"type": "Movie",
"genres": ["Action", "Drama"],
"tags": ["tag1", "tag2"],
"studios": ["Studio1"],
"runtime": 120,
"director": "Regisseur",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
```
### Cast-Objekt (Stammdaten)
```json
{
"id": 1,
"name": "Name",
"photo": "URL",
"bio": "Biografie",
"birthDate": "1990-01-01",
"birthPlace": "Ort",
"occupations": ["Actor", "Producer"],
"filmography": [
{
"id": 1,
"title": "Film Titel",
"year": "2020",
"role": "Actor",
"characterName": "Charakter"
}
]
}
```
### Cast-Zuordnung in Media
```json
{
"staff": [
{
"id": 1,
"role": "Actor",
"characterName": "Cobb",
"characterImage": "URL"
},
{
"name": "Neuer Schauspieler",
"role": "Actor",
"characterName": "Charakter"
}
]
}
```
### Episode-Objekt
```json
{
"id": 1,
"media_id": 1,
"season": 1,
"episode_number": 1,
"title": "Pilot",
"description": "Beschreibung der Episode",
"air_date": "2008-01-20",
"duration": 58,
"thumbnail": "URL"
}
```
### Track-Objekt
```json
{
"id": 1,
"media_id": 2,
"track_number": 1,
"title": "Speak to Me",
"duration": 90,
"artist": "Pink Floyd"
}
```
### Adult-Specifics Objekt
```json
{
"id": 1,
"cast_id": 1,
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": 170,
"weight": 55,
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian",
"tattoos": "None",
"piercings": "Ears",
"measurements": "34D-24-34",
"shoe_size": "38"
}
```
## Features
- ✅ Vollständige CRUD-Operationen
- ✅ SQLite-Datenbank (keine zusätzliche Konfiguration nötig)
- ✅ JSON-basierte API
- ✅ CORS-Unterstützung
- ✅ Filterung und Suche
- ✅ Pagination
- ✅ Automatische Datenbank-Erstellung
- ✅ Relationale Daten (Genres, Tags, Studios, Cast)
- ✅ n:m Beziehung zwischen Cast und Media (Cast-Mitglied nur einmal anlegen, mehreren Medien zuordnen)
- ✅ Automatische Cast-Erkennung anhand Name beim Media-Upload
- ✅ Filmographie für jedes Cast-Mitglied
- ✅ Modularer Aufbau mit MVC-ähnlicher Struktur
- ✅ Typ-spezifische Modelle (Movie, Series, Music, Game, Console, Adult)
- ✅ Erweiterbare Architektur für neue Medientypen
- ✅ Episoden und Staffeln für Serien
- ✅ Tracklisten für Musikalben
- ✅ Typ-spezifische Endpunkte für Episoden und Tracks
- ✅ Adult-Actors mit erweiterten Informationen (Maße, Aussehen, etc.)
- ✅ Filterung von Adult-Actors nach Ethnie, Haarfarbe, etc.
- ✅ Automatische API-Dokumentation via GET /api/docs
- ✅ Dynamische Dokumentation aus PHPDoc-Kommentaren
## Erweiterung für neue Medientypen
Um einen neuen Medientyp hinzuzufügen:
1. Neue Klasse in `models/` erstellen, die von `MediaType` erbt:
```php
<?php
require_once __DIR__ . '/MediaType.php';
class Book extends MediaType {
protected function getType() {
return 'Book';
}
protected function getTypeSpecificFields() {
return ['author', 'isbn', 'pages'];
}
protected function validateTypeSpecificFields($data) {
// Typ-spezifische Validierung
}
}
```
2. Controller im Router registrieren (optional)
3. Datenbank-Tabelle in `database.php` hinzufügen falls nötig

116
api/Router.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
require_once __DIR__ . '/controllers/MediaController.php';
require_once __DIR__ . '/controllers/CastController.php';
require_once __DIR__ . '/controllers/ImageController.php';
require_once __DIR__ . '/controllers/SettingsController.php';
require_once __DIR__ . '/services/DocumentationService.php';
require_once __DIR__ . '/services/ApiLogger.php';
class Router {
private $pdo;
private $mediaController;
private $castController;
private $imageController;
private $settingsController;
private $documentationService;
private $logger;
public function __construct($pdo) {
$this->pdo = $pdo;
$this->mediaController = new MediaController($pdo);
$this->castController = new CastController($pdo);
$this->imageController = new ImageController();
$this->settingsController = new SettingsController($pdo);
$this->documentationService = new DocumentationService();
$this->logger = ApiLogger::getInstance();
}
public function route($method, $pathSegments) {
$path = '/' . implode('/', $pathSegments);
$queryString = $_SERVER['QUERY_STRING'] ?? '';
$fullPath = $queryString ? $path . '?' . $queryString : $path;
// Request loggen
$body = null;
if ($method === 'POST' || $method === 'PUT') {
$body = json_decode(file_get_contents('php://input'), true);
}
$this->logger->logRequest($method, $fullPath, $_GET, $body);
if (empty($pathSegments)) {
$response = $this->getRoot();
$this->logger->logResponse($method, $fullPath, 200, $response);
return $response;
}
$resource = $pathSegments[0];
try {
switch ($resource) {
case 'images':
// Images are served directly, bypass JSON response
$this->imageController->handleRequest($method, $pathSegments);
exit;
case 'media':
$response = $this->mediaController->handleRequest($method, $pathSegments);
$statusCode = http_response_code();
$this->logger->logResponse($method, $fullPath, $statusCode, $response);
return $response;
case 'cast':
$response = $this->castController->handleRequest($method, $pathSegments);
$statusCode = http_response_code();
$this->logger->logResponse($method, $fullPath, $statusCode, $response);
return $response;
case 'settings':
$response = $this->settingsController->handleRequest($method, $pathSegments);
$statusCode = http_response_code();
$this->logger->logResponse($method, $fullPath, $statusCode, $response);
return $response;
case 'docs':
$response = $this->getDocumentation();
$this->logger->logResponse($method, $fullPath, 200, $response);
return $response;
default:
http_response_code(404);
$response = ['success' => false, 'error' => 'Endpoint not found'];
$this->logger->logResponse($method, $fullPath, 404, $response);
return $response;
}
} catch (Exception $e) {
http_response_code(500);
$response = ['success' => false, 'error' => $e->getMessage()];
$this->logger->logError($method, $fullPath, $e->getMessage());
return $response;
}
}
private function getDocumentation() {
$docs = $this->documentationService->generateDocumentation();
return ['success' => true, 'data' => $docs];
}
private function getRoot() {
return [
'success' => true,
'message' => 'Media API v1.0',
'endpoints' => [
'GET /api/docs' => 'Automatische API-Dokumentation',
'GET /api/images/*' => 'Bilder abrufen (z.B. /api/images/games/poster_xxx.webp)',
'GET /api/media' => 'Alle Medien abrufen',
'GET /api/media/:id' => 'Ein Medium abrufen',
'POST /api/media' => 'Neues Medium erstellen',
'PUT /api/media/:id' => 'Medium aktualisieren',
'DELETE /api/media/:id' => 'Medium löschen',
'GET /api/cast' => 'Alle Cast-Mitglieder abrufen',
'GET /api/cast/:id' => 'Cast-Mitglied abrufen',
'GET /api/cast/:id/media' => 'Alle Medien eines Cast-Mitglieds abrufen',
'POST /api/cast' => 'Neues Cast-Mitglied erstellen',
'PUT /api/cast/:id' => 'Cast-Mitglied aktualisieren',
'DELETE /api/cast/:id' => 'Cast-Mitglied löschen',
'GET /api/settings' => 'Einstellungen abrufen',
'PUT /api/settings' => 'Einstellungen aktualisieren'
]
];
}
}

39
api/config.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
// Konfiguration
// Docker-Umgebungsvariablen oder Standardwerte
define('DB_HOST', getenv('DB_HOST') ?: 'mariadb');
define('DB_NAME', getenv('DB_NAME') ?: 'kyoo');
define('DB_USER', getenv('DB_USER') ?: 'kyoo_user');
define('DB_PASS', getenv('DB_PASS') ?: 'kyoo_password');
define('API_BASE', '/api');
// API Logging Konfiguration
define('API_LOGGING_ENABLED', getenv('API_LOGGING_ENABLED') === 'true' || false); // Standardmäßig aktiviert zum Testen
define('API_LOG_FILE', '/var/www/html/logs/api.log');
// PHP Error Logging Konfiguration
define('PHP_ERROR_LOG_FILE', '/var/www/html/logs/php_error.log');
// Hilfsfunktion für cleanname Generierung
function generateCleanName($name) {
// Kleinbuchstaben, nur alphanumerische Zeichen und Bindestriche
$clean = strtolower($name);
// Sonderzeichen durch Bindestriche ersetzen
$clean = preg_replace('/[^a-z0-9]+/', '-', $clean);
// Bindestriche am Anfang und Ende entfernen
$clean = trim($clean, '-');
// Mehrere Bindestriche durch einen ersetzen
$clean = preg_replace('/-+/', '-', $clean);
return $clean;
}
// CORS-Header
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// OPTIONS-Request behandeln
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}

View File

@@ -0,0 +1,256 @@
<?php
require_once __DIR__ . '/../models/Cast.php';
require_once __DIR__ . '/../models/AdultCast.php';
require_once __DIR__ . '/../services/ApiLogger.php';
class CastController {
private $cast;
private $adultCast;
private $logger;
public function __construct($pdo) {
$this->cast = new Cast($pdo);
$this->adultCast = new AdultCast($pdo);
$this->logger = ApiLogger::getInstance();
}
public function handleRequest($method, $segments) {
$id = isset($segments[1]) ? (int)$segments[1] : null;
$subResource = isset($segments[2]) ? $segments[2] : null;
$path = '/' . implode('/', $segments);
$this->logger->logRequest($method, $path);
// Adult-spezifische Endpunkte
if ($id === 'adult' || $subResource === 'adult') {
// die("adult");
return $this->handleAdult($method, $id, $segments);
}
switch ($method) {
case 'GET':
return $id ? $this->getOne($id, $segments) : $this->getAll();
case 'POST':
return $this->create();
case 'PUT':
return $this->update($id);
case 'DELETE':
return $this->delete($id);
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function handleAdult($method, $id, $segments) {
switch ($method) {
case 'GET':
if ($id) {
return $this->getAdultOne($id);
}
return $this->getAdultAll();
case 'POST':
return $this->createAdult();
case 'PUT':
return $this->updateAdult($id);
case 'DELETE':
return $this->deleteAdultSpecifics($id);
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function getAdultAll() {
$filters = [];
if (isset($_GET['search'])) $filters['search'] = $_GET['search'];
if (isset($_GET['ethnicity'])) $filters['ethnicity'] = $_GET['ethnicity'];
if (isset($_GET['hair_color'])) $filters['hair_color'] = $_GET['hair_color'];
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20;
$result = $this->adultCast->searchAdultActors($filters, $page, $limit);
return ['success' => true, 'data' => $result];
}
private function getAdultOne($id) {
$cast = $this->adultCast->getWithAdultSpecifics($id);
if (!$cast) {
http_response_code(404);
return ['success' => false, 'error' => 'Adult actor not found'];
}
return ['success' => true, 'data' => $cast];
}
private function createAdult() {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$name = $data['name'] ?? null;
if (!$name) {
http_response_code(400);
return ['success' => false, 'error' => 'Name is required'];
}
// Prüfen ob bereits Eintrag mit diesem cleanname existiert
$cleanname = generateCleanName($name);
$existing = $this->cast->findByCleanName($cleanname);
if ($existing) {
// Update existing cast member with new photo if provided
if (isset($data['photo']) && !empty($data['photo'])) {
$this->adultCast->updateWithAdultSpecifics($existing['id'], $data);
}
http_response_code(200);
$this->logger->logRequest('POST', '/api/cast/adult', [], $data);
$this->logger->logResponse('POST', '/api/cast/adult', 200, ['id' => $existing['id'], 'message' => 'Cast already exists']);
return ['success' => true, 'data' => ['id' => $existing['id'], 'message' => 'Cast already exists']];
}
$castId = $this->adultCast->createWithAdultSpecifics($data);
http_response_code(201);
$this->logger->logRequest('POST', '/api/cast/adult', [], $data);
$this->logger->logResponse('POST', '/api/cast/adult', 201, ['id' => $castId]);
return ['success' => true, 'data' => ['id' => $castId]];
}
private function updateAdult($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$this->adultCast->updateWithAdultSpecifics($id, $data);
$this->logger->logRequest('PUT', "/api/cast/adult/$id", [], $data);
$this->logger->logResponse('PUT', "/api/cast/adult/$id", 200, ['id' => $id]);
return ['success' => true, 'data' => ['id' => $id]];
}
private function deleteAdultSpecifics($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$deleted = $this->adultCast->deleteAdultSpecifics($id);
if (!$deleted) {
http_response_code(404);
return ['success' => false, 'error' => 'Adult specifics not found'];
}
$this->logger->logRequest('DELETE', "/api/cast/adult/$id", [], null);
$this->logger->logResponse('DELETE', "/api/cast/adult/$id", 200, ['message' => 'Adult specifics deleted successfully']);
return ['success' => true, 'message' => 'Adult specifics deleted successfully'];
}
private function getOne($id, $segments) {
// Prüfen ob /media angehängt wurde
if (isset($segments[2]) && $segments[2] === 'media') {
return $this->getMedia($id);
}
$cast = $this->cast->getWithFilmography($id);
$cast['adult_specifics'] = $this->adultCast->getAdultSpecifics($id);
if (!$cast) {
http_response_code(404);
return ['success' => false, 'error' => 'Cast member not found'];
}
return ['success' => true, 'data' => $cast];
}
private function getMedia($castId) {
$media = $this->cast->getMediaForCast($castId);
return ['success' => true, 'data' => ['items' => $media]];
}
private function getAll() {
$filters = [];
if (isset($_GET['search'])) $filters['search'] = $_GET['search'];
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20;
$result = $this->cast->search($filters, $page, $limit);
return ['success' => true, 'data' => $result];
}
private function create() {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$name = $data['name'] ?? null;
if (!$name) {
http_response_code(400);
return ['success' => false, 'error' => 'Name is required'];
}
// Prüfen ob bereits Eintrag mit diesem cleanname existiert
$cleanname = generateCleanName($name);
$existing = $this->cast->findByCleanName($cleanname);
if ($existing) {
// Update existing cast member with new photo if provided
if (isset($data['photo']) && !empty($data['photo'])) {
$this->cast->updateWithOccupations($existing['id'], $data);
}
http_response_code(200);
$this->logger->logRequest('POST', '/api/cast', [], $data);
$this->logger->logResponse('POST', '/api/cast', 200, ['id' => $existing['id'], 'message' => 'Cast already exists']);
return ['success' => true, 'data' => ['id' => $existing['id'], 'message' => 'Cast already exists']];
}
$castId = $this->cast->createWithOccupations($data);
http_response_code(201);
$this->logger->logRequest('POST', '/api/cast', [], $data);
$this->logger->logResponse('POST', '/api/cast', 201, ['id' => $castId]);
return ['success' => true, 'data' => ['id' => $castId]];
}
private function update($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$this->cast->updateWithOccupations($id, $data);
$this->logger->logRequest('PUT', "/api/cast/$id", [], $data);
$this->logger->logResponse('PUT', "/api/cast/$id", 200, ['id' => $id]);
return ['success' => true, 'data' => ['id' => $id]];
}
private function delete($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$deleted = $this->cast->delete($id);
if (!$deleted) {
http_response_code(404);
return ['success' => false, 'error' => 'Cast member not found'];
}
$this->logger->logRequest('DELETE', "/api/cast/$id", [], null);
$this->logger->logResponse('DELETE', "/api/cast/$id", 200, ['message' => 'Cast member deleted successfully']);
return ['success' => true, 'message' => 'Cast member deleted successfully'];
}
}

View File

@@ -0,0 +1,68 @@
<?php
class ImageController {
private $imageDir;
public function __construct() {
$this->imageDir = __DIR__ . '/../public/images/';
}
public function handleRequest($method, $pathSegments) {
// Remove 'images' from path segments
array_shift($pathSegments);
// Build file path
$imagePath = implode('/', $pathSegments);
$fullPath = $this->imageDir . $imagePath;
// Security check: ensure the path is within the images directory
$realPath = realpath($fullPath);
$realImageDir = realpath($this->imageDir);
if ($realPath === false || strpos($realPath, $realImageDir) !== 0) {
http_response_code(403);
return ['success' => false, 'error' => 'Access denied'];
}
// Check if file exists
if (!file_exists($realPath)) {
http_response_code(404);
return ['success' => false, 'error' => 'Image not found'];
}
// Check if it's actually a file
if (!is_file($realPath)) {
http_response_code(404);
return ['success' => false, 'error' => 'Not a file'];
}
// Get file info
$fileInfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($fileInfo, $realPath);
finfo_close($fileInfo);
if ($mimeType === false) {
// Fallback to common image types
$extension = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml'
];
$mimeType = $mimeTypes[$extension] ?? 'application/octet-stream';
}
// Set headers for image serving
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($realPath));
header('Cache-Control: public, max-age=31536000'); // Cache for 1 year
header('Pragma: public');
// Output the image
readfile($realPath);
exit;
}
}

View File

@@ -0,0 +1,403 @@
<?php
require_once __DIR__ . '/../models/Media.php';
require_once __DIR__ . '/../models/Series.php';
require_once __DIR__ . '/../models/Music.php';
require_once __DIR__ . '/../models/Game.php';
require_once __DIR__ . '/../services/ApiLogger.php';
class MediaController {
private $media;
private $series;
private $music;
private $game;
private $logger;
public function __construct($pdo) {
$this->media = new Media($pdo);
$this->series = new Series($pdo);
$this->music = new Music($pdo);
$this->game = new Game($pdo);
$this->logger = ApiLogger::getInstance();
}
public function handleRequest($method, $segments) {
$id = isset($segments[1]) ? (int)$segments[1] : null;
$subResource = isset($segments[2]) ? $segments[2] : null;
// Sub-Endpunkte für Episoden und Tracks
if ($id && $subResource) {
if ($subResource === 'episodes') {
return $this->handleEpisodes($method, $id, $segments);
}
if ($subResource === 'tracks') {
return $this->handleTracks($method, $id, $segments);
}
}
switch ($method) {
case 'GET':
return $id ? $this->getOne($id) : $this->getAll();
case 'POST':
return $this->create();
case 'PUT':
return $this->update($id);
case 'DELETE':
return $this->delete($id);
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function handleEpisodes($method, $mediaId, $segments) {
$episodeId = isset($segments[3]) ? (int)$segments[3] : null;
switch ($method) {
case 'GET':
if ($episodeId) {
return $this->getEpisode($episodeId);
}
return $this->getEpisodes($mediaId);
case 'POST':
return $this->addEpisode($mediaId);
case 'PUT':
return $this->updateEpisode($episodeId);
case 'DELETE':
return $this->deleteEpisode($episodeId);
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function handleTracks($method, $mediaId, $segments) {
$trackId = isset($segments[3]) ? (int)$segments[3] : null;
switch ($method) {
case 'GET':
if ($trackId) {
return $this->getTrack($trackId);
}
return $this->getTracks($mediaId);
case 'POST':
return $this->addTrack($mediaId);
case 'PUT':
return $this->updateTrack($trackId);
case 'DELETE':
return $this->deleteTrack($trackId);
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function getEpisodes($mediaId) {
$season = isset($_GET['season']) ? (int)$_GET['season'] : null;
$episodes = $this->series->getEpisodes($mediaId, $season);
return ['success' => true, 'data' => ['items' => $episodes]];
}
/**
* Add a new episode to a series
* @param int $mediaId Media ID
* @return array Created episode ID
*/
private function addEpisode($mediaId) {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$episodeId = $this->series->addEpisode($mediaId, $data);
http_response_code(201);
return ['success' => true, 'data' => ['id' => $episodeId]];
}
/**
* Update an existing episode
* @param int $episodeId Episode ID
* @return array Updated episode ID
*/
private function updateEpisode($episodeId) {
if (!$episodeId) {
http_response_code(400);
return ['success' => false, 'error' => 'Episode ID required'];
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$this->series->updateEpisode($episodeId, $data);
return ['success' => true, 'data' => ['id' => $episodeId]];
}
/**
* Delete an episode
* @param int $episodeId Episode ID
* @return array Success message
*/
private function deleteEpisode($episodeId) {
if (!$episodeId) {
http_response_code(400);
return ['success' => false, 'error' => 'Episode ID required'];
}
$deleted = $this->series->deleteEpisode($episodeId);
if (!$deleted) {
http_response_code(404);
return ['success' => false, 'error' => 'Episode not found'];
}
return ['success' => true, 'message' => 'Episode deleted successfully'];
}
/**
* Get a single episode by ID
* @param int $episodeId Episode ID
* @return array Episode data
*/
private function getEpisode($episodeId) {
// Episode direkt aus Datenbank abrufen
$stmt = $this->series->getConnection()->prepare("SELECT * FROM episodes WHERE id = ?");
$stmt->execute([$episodeId]);
$episode = $stmt->fetch();
if (!$episode) {
http_response_code(404);
return ['success' => false, 'error' => 'Episode not found'];
}
return ['success' => true, 'data' => $episode];
}
private function getTracks($mediaId) {
$tracks = $this->music->getTracks($mediaId);
return ['success' => true, 'data' => ['items' => $tracks]];
}
private function addTrack($mediaId) {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$trackId = $this->music->addTrack($mediaId, $data);
http_response_code(201);
return ['success' => true, 'data' => ['id' => $trackId]];
}
private function updateTrack($trackId) {
if (!$trackId) {
http_response_code(400);
return ['success' => false, 'error' => 'Track ID required'];
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$this->music->updateTrack($trackId, $data);
return ['success' => true, 'data' => ['id' => $trackId]];
}
private function deleteTrack($trackId) {
if (!$trackId) {
http_response_code(400);
return ['success' => false, 'error' => 'Track ID required'];
}
$deleted = $this->music->deleteTrack($trackId);
if (!$deleted) {
http_response_code(404);
return ['success' => false, 'error' => 'Track not found'];
}
return ['success' => true, 'message' => 'Track deleted successfully'];
}
private function getTrack($trackId) {
// Track direkt aus Datenbank abrufen
$stmt = $this->music->getConnection()->prepare("SELECT * FROM tracks WHERE id = ?");
$stmt->execute([$trackId]);
$track = $stmt->fetch();
if (!$track) {
http_response_code(404);
return ['success' => false, 'error' => 'Track not found'];
}
return ['success' => true, 'data' => $track];
}
/**
* Get a single media item by ID
* @param int $id Media ID
* @return array Media object with relations
*/
private function getOne($id) {
// Zuerst Basis-Media abrufen um Typ zu bestimmen
$baseMedia = $this->media->getBase($id);
if (!$baseMedia) {
http_response_code(404);
return ['success' => false, 'error' => 'Media not found'];
}
// Typ-spezifisches Abrufen
switch ($baseMedia['type']) {
case 'TV':
$media = $this->series->getWithEpisodes($id);
break;
case 'Album':
$media = $this->music->getWithTracks($id);
break;
case 'Game':
$media = $this->game->getWithGameInfo($id);
break;
default:
$media = $this->media->getWithRelations($id);
}
return ['success' => true, 'data' => $media];
}
/**
* Get all media items with filtering and pagination
* @return array Paginated media list
*/
private function getAll() {
$filters = [];
if (isset($_GET['category'])) $filters['category'] = $_GET['category'];
if (isset($_GET['type'])) $filters['type'] = $_GET['type'];
if (isset($_GET['search'])) $filters['search'] = $_GET['search'];
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20;
$result = $this->media->search($filters, $page, $limit);
// Game-spezifische Daten für Games laden
foreach ($result['items'] as &$item) {
if ($item['type'] === 'Game') {
$gameInfo = $this->game->getGameInfoForList($item['id']);
if ($gameInfo) {
$item = array_merge($item, $gameInfo);
}
}
}
return ['success' => true, 'data' => $result];
}
/**
* Create a new media item
* @return array Created media ID
*/
private function create() {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
error_log("MediaController::create - Data received, poster field exists: " . (isset($data['poster']) ? 'yes' : 'no'));
if (isset($data['poster'])) {
error_log("MediaController::create - Poster length: " . strlen($data['poster']));
error_log("MediaController::create - Poster starts with: " . substr($data['poster'], 0, 50));
}
$title = $data['title'] ?? null;
if (!$title) {
http_response_code(400);
return ['success' => false, 'error' => 'Title is required'];
}
// Prüfen ob bereits Eintrag mit diesem cleanname existiert
$cleanname = generateCleanName($title);
$existing = $this->media->findByCleanName($cleanname);
if ($existing) {
http_response_code(200);
$this->logger->logRequest('POST', '/api/media', [], $data);
$this->logger->logResponse('POST', '/api/media', 200, ['id' => $existing['id'], 'message' => 'Media already exists']);
return ['success' => true, 'data' => ['id' => $existing['id'], 'message' => 'Media already exists']];
}
// Typ-spezifisches Erstellen
$type = $data['type'] ?? null;
if ($type === 'Game') {
$mediaId = $this->game->createWithRelations($data);
} elseif ($type === 'TV') {
$mediaId = $this->series->createWithRelations($data);
} elseif ($type === 'Album') {
$mediaId = $this->music->createWithRelations($data);
} else {
$mediaId = $this->media->createWithRelations($data);
}
http_response_code(201);
$this->logger->logRequest('POST', '/api/media', [], $data);
$this->logger->logResponse('POST', '/api/media', 201, ['id' => $mediaId]);
return ['success' => true, 'data' => ['id' => $mediaId]];
}
/**
* Update an existing media item
* @param int $id Media ID
* @return array Updated media ID
*/
private function update($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
// Typ-spezifisches Aktualisieren
$type = $data['type'] ?? null;
if ($type === 'Game') {
$this->game->updateWithRelations($id, $data);
} elseif ($type === 'TV') {
$this->series->updateWithRelations($id, $data);
} elseif ($type === 'Album') {
$this->music->updateWithRelations($id, $data);
} else {
$this->media->updateWithRelations($id, $data);
}
$this->logger->logRequest('PUT', "/api/media/$id", [], $data);
$this->logger->logResponse('PUT', "/api/media/$id", 200, ['id' => $id]);
return ['success' => true, 'data' => ['id' => $id]];
}
/**
* Delete a media item
* @param int $id Media ID
* @return array Success message
*/
private function delete($id) {
if (!$id) {
http_response_code(400);
return ['success' => false, 'error' => 'ID required'];
}
$deleted = $this->media->delete($id);
if (!$deleted) {
http_response_code(404);
return ['success' => false, 'error' => 'Media not found'];
}
$this->logger->logRequest('DELETE', "/api/media/$id", [], null);
$this->logger->logResponse('DELETE', "/api/media/$id", 200, ['message' => 'Media deleted successfully']);
return ['success' => true, 'message' => 'Media deleted successfully'];
}
}

View File

@@ -0,0 +1,61 @@
<?php
require_once __DIR__ . '/../models/Settings.php';
require_once __DIR__ . '/../services/ApiLogger.php';
class SettingsController {
private $settings;
private $logger;
public function __construct($pdo) {
$this->settings = new Settings($pdo);
$this->logger = ApiLogger::getInstance();
}
public function handleRequest($method, $segments) {
$path = '/' . implode('/', $segments);
$this->logger->logRequest($method, $path);
switch ($method) {
case 'GET':
return $this->get();
case 'PUT':
return $this->update();
default:
http_response_code(405);
return ['success' => false, 'error' => 'Method not allowed'];
}
}
private function get() {
$settings = $this->settings->getSettings();
if (!$settings) {
http_response_code(404);
return ['success' => false, 'error' => 'Settings not found'];
}
return ['success' => true, 'data' => $settings];
}
private function update() {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
return ['success' => false, 'error' => 'Invalid JSON'];
}
$settings = $this->settings->updateSettings($data);
if (!$settings) {
http_response_code(500);
return ['success' => false, 'error' => 'Failed to update settings'];
}
$this->logger->logRequest('PUT', '/api/settings', [], $data);
$this->logger->logResponse('PUT', '/api/settings', 200, $settings);
return ['success' => true, 'data' => $settings];
}
}

388
api/database.php Normal file
View File

@@ -0,0 +1,388 @@
<?php
require_once __DIR__ . '/config.php';
class Database {
private $pdo;
public function __construct() {
try {
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$this->pdo = new PDO($dsn, DB_USER, DB_PASS);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->initializeTables();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed: ' . $e->getMessage()]);
exit;
}
}
private function initializeTables() {
// Media-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS media (
id INT AUTO_INCREMENT PRIMARY KEY,
title TEXT NOT NULL,
cleanname TEXT,
year TEXT,
poster TEXT,
banner TEXT,
description TEXT,
rating FLOAT,
category TEXT,
type TEXT,
status TEXT,
aspectRatio TEXT,
runtime INT,
director TEXT,
writer TEXT,
releaseDate TEXT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
");
// cleanname Feld zu bestehender Tabelle hinzufügen, falls nicht vorhanden
try {
$this->pdo->exec("ALTER TABLE media ADD COLUMN cleanname TEXT");
} catch (Exception $e) {
// Feld existiert bereits
}
// Index für cleanname erstellen
try {
$this->pdo->exec("CREATE INDEX idx_media_cleanname ON media(cleanname)");
} catch (Exception $e) {
// Index existiert bereits
}
// Genres-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS genres (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
genre TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Tags-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
tag TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Studios-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS studios (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
studio TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Cast/Staff-Tabelle (Stammdaten - ohne media_id)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS cast_staff (
id INT AUTO_INCREMENT PRIMARY KEY,
name TEXT NOT NULL,
cleanname TEXT,
photo TEXT,
bio TEXT,
birthDate TEXT,
birthPlace TEXT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
");
// cleanname Feld zu bestehender Tabelle hinzufügen, falls nicht vorhanden
try {
$this->pdo->exec("ALTER TABLE cast_staff ADD COLUMN cleanname TEXT");
} catch (Exception $e) {
// Feld existiert bereits
}
// Index für cleanname erstellen
try {
$this->pdo->exec("CREATE INDEX idx_cast_staff_cleanname ON cast_staff(cleanname)");
} catch (Exception $e) {
// Index existiert bereits
}
// n:m Beziehung zwischen Media und Cast
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS media_cast (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
cast_id INT,
role TEXT,
characterName TEXT,
characterImage TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
FOREIGN KEY (cast_id) REFERENCES cast_staff(id) ON DELETE CASCADE
)
");
// Occupations-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS occupations (
id INT AUTO_INCREMENT PRIMARY KEY,
cast_id INT,
occupation TEXT,
FOREIGN KEY (cast_id) REFERENCES cast_staff(id) ON DELETE CASCADE
)
");
// Episodes-Tabelle (für Series)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS episodes (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
season INT,
episode_number INT,
title TEXT,
description TEXT,
air_date TEXT,
duration INT,
thumbnail TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Tracks-Tabelle (für Music/Albums)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS tracks (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT,
track_number INT,
title TEXT,
duration INT,
artist TEXT,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Adult-spezifische Cast-Infos
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS adult_cast_specifics (
id INT AUTO_INCREMENT PRIMARY KEY,
cast_id INT UNIQUE,
bust_size TEXT,
cup_size TEXT,
waist_size TEXT,
hip_size TEXT,
height INT,
weight INT,
hair_color TEXT,
eye_color TEXT,
ethnicity TEXT,
tattoos TEXT,
piercings TEXT,
measurements TEXT,
shoe_size TEXT,
FOREIGN KEY (cast_id) REFERENCES cast_staff(id) ON DELETE CASCADE
)
");
// Media-Games Tabelle (1:1 Relation zu media für spiel-spezifische Daten)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS media_games (
id INT AUTO_INCREMENT PRIMARY KEY,
media_id INT UNIQUE,
sortingName TEXT,
notes TEXT,
completionStatus TEXT,
source TEXT,
gameId TEXT,
pluginId TEXT,
isInstalled BOOLEAN DEFAULT 0,
installDirectory TEXT,
installSize BIGINT DEFAULT 0,
hidden BOOLEAN DEFAULT 0,
favorite BOOLEAN DEFAULT 0,
playCount INT DEFAULT 0,
lastActivity TIMESTAMP NULL,
added TIMESTAMP NULL,
modified TIMESTAMP NULL,
communityScore INT DEFAULT 0,
criticScore INT DEFAULT 0,
userScore INT DEFAULT 0,
hasIcon BOOLEAN DEFAULT 0,
hasCover BOOLEAN DEFAULT 0,
hasBackground BOOLEAN DEFAULT 0,
version TEXT,
playtime INT DEFAULT 0,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
)
");
// Achievements-Tabelle (für Games - referenziert media_games)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
unlocked BOOLEAN DEFAULT 0,
unlocked_date TIMESTAMP NULL,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Categories-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
category TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Features-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_features (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
feature TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Platforms-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_platforms (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
platform TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Developers-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_developers (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
developer TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Publishers-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_publishers (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
publisher TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Series-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_series (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
series TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Age Ratings-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_age_ratings (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
age_rating TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Regions-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
region TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// Game Links-Tabelle (mit name und url)
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS game_links (
id INT AUTO_INCREMENT PRIMARY KEY,
media_game_id INT,
name TEXT,
url TEXT,
FOREIGN KEY (media_game_id) REFERENCES media_games(id) ON DELETE CASCADE
)
");
// API Logs-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS api_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
type TEXT NOT NULL,
method TEXT,
path TEXT,
params JSON,
body JSON,
status_code INT,
response JSON,
error TEXT,
INDEX idx_timestamp (timestamp),
INDEX idx_type (type)
)
");
// Settings-Tabelle
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
enabled_categories JSON,
items_per_page INT DEFAULT 20,
default_view TEXT DEFAULT 'grid',
show_adult_content BOOLEAN DEFAULT 0,
auto_play_trailers BOOLEAN DEFAULT 0,
language TEXT DEFAULT 'en',
theme TEXT DEFAULT 'system',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
");
// Bestehende Einträge mit cleanname aktualisieren
$this->updateExistingCleanNames();
}
private function updateExistingCleanNames() {
// Media cleanname aktualisieren
$this->pdo->exec("
UPDATE media
SET cleanname = LOWER(REPLACE(REPLACE(REPLACE(REPLACE(title, ' ', '-'), '.', '-'), ',', '-'), '--', '-'))
WHERE cleanname IS NULL OR cleanname = ''
");
// Cast cleanname aktualisieren
$this->pdo->exec("
UPDATE cast_staff
SET cleanname = LOWER(REPLACE(REPLACE(REPLACE(REPLACE(name, ' ', '-'), '.', '-'), ',', '-'), '--', '-'))
WHERE cleanname IS NULL OR cleanname = ''
");
}
public function getConnection() {
return $this->pdo;
}
}

37
api/index.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
error_log("API Request: " . $_SERVER['REQUEST_METHOD'] . " " . $_SERVER['REQUEST_URI']);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/Router.php';
// JSON-Response-Header
header('Content-Type: application/json');
// Request-Method und Pfad ermitteln
$method = $_SERVER['REQUEST_METHOD'];
$requestUri = $_SERVER['REQUEST_URI'];
$path = parse_url($requestUri, PHP_URL_PATH);
// Base-Path entfernen
$basePath = API_BASE;
if (strpos($path, $basePath) === 0) {
$path = substr($path, strlen($basePath));
}
// Pfad in Segmente aufteilen
$pathSegments = array_filter(explode('/', trim($path, '/')));
// Datenbankverbindung und Router
$db = new Database();
$pdo = $db->getConnection();
$router = new Router($pdo);
// Routing
try {
$response = $router->route($method, $pathSegments);
echo json_encode($response);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

85
api/models/Adult.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/MediaType.php';
require_once __DIR__ . '/../services/ImageHandler.php';
class Adult extends MediaType {
private $imageHandler;
private $isUpdate = false;
private $mediaId = null;
public function __construct($pdo) {
parent::__construct($pdo);
$this->imageHandler = new ImageHandler();
}
protected function getType() {
return 'Adult';
}
protected function getTypeSpecificFields() {
return [];
}
protected function validateTypeSpecificFields($data) {
// Adult spezifische Validierung
// Eventuell Altersverifikation etc.
}
protected function processPosterField($data) {
error_log("Adult::processPosterField - Checking for poster field, isUpdate: " . ($this->isUpdate ? 'yes' : 'no'));
if ($this->isUpdate && $this->mediaId && isset($data['poster']) && !empty($data['poster'])) {
$currentMedia = $this->findById($this->mediaId);
if ($currentMedia && !empty($currentMedia['poster'])) {
$oldPoster = $currentMedia['poster'];
if (strpos($oldPoster, '/images/') === 0) {
error_log("Adult::processPosterField - Deleting old poster: " . $oldPoster);
$this->imageHandler->deleteImage($oldPoster);
}
}
}
if (isset($data['poster']) && !empty($data['poster'])) {
error_log("Adult::processPosterField - Poster found, length: " . strlen($data['poster']));
if (strpos($data['poster'], '/images/') === 0 || filter_var($data['poster'], FILTER_VALIDATE_URL)) {
error_log("Adult::processPosterField - Poster is already a path or URL, skipping processing");
return $data;
}
$posterPath = $this->imageHandler->saveBase64Image($data['poster'], 'adult/poster');
error_log("Adult::processPosterField - ImageHandler returned: " . ($posterPath ?: 'null'));
if ($posterPath) {
$data['poster'] = $posterPath;
error_log("Adult::processPosterField - Poster path set to: " . $posterPath);
} else {
error_log("Adult::processPosterField - Failed to process poster, keeping original data");
}
} else {
error_log("Adult::processPosterField - No poster field found or empty");
}
return $data;
}
public function createWithRelations($data) {
$data['type'] = 'Adult';
$this->validateTypeSpecificFields($data);
$data = $this->processPosterField($data);
return parent::createWithRelations($data);
}
public function updateWithRelations($id, $data) {
$this->isUpdate = true;
$this->mediaId = $id;
$this->validateTypeSpecificFields($data);
$data = $this->processPosterField($data);
parent::updateWithRelations($id, $data);
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Adult Content suchen
$filters['type'] = 'Adult';
return parent::search($filters, $page, $limit);
}
}

295
api/models/AdultCast.php Normal file
View File

@@ -0,0 +1,295 @@
<?php
require_once __DIR__ . '/Cast.php';
require_once __DIR__ . '/../services/ApiLogger.php';
class AdultCast extends Cast {
public function getWithAdultSpecifics($id) {
$cast = $this->getWithFilmography($id);
if (!$cast) {
return null;
}
$cast['adult_specifics'] = $this->getAdultSpecifics($id);
return $cast;
}
public function getAdultSpecifics($castId) {
$stmt = $this->pdo->prepare("SELECT * FROM adult_cast_specifics WHERE cast_id = ?");
$stmt->execute([$castId]);
return $stmt->fetch();
}
public function createWithAdultSpecifics($data) {
ApiLogger::getInstance()->logDebug("AdultCast createWithAdultSpecifics called with data: " . json_encode($data));
$name = $data['name'] ?? null;
if (!$name) {
throw new Exception('Name is required');
}
// cleanname generieren
$cleanname = generateCleanName($name);
// Process photo field (base64 to file path)
$data = $this->processPhotoField($data);
// Zuerst Basis-Cast erstellen
$castData = [
'name' => $name,
'cleanname' => $cleanname,
'photo' => $data['photo'] ?? null,
'bio' => $data['bio'] ?? null,
'birthDate' => $data['birthDate'] ?? null,
'birthPlace' => $data['birthPlace'] ?? null
];
$castId = $this->create($castData);
ApiLogger::getInstance()->logDebug("AdultCast createWithAdultSpecifics: Base cast created with id: $castId");
// Occupations speichern
if (isset($data['occupations']) && is_array($data['occupations'])) {
ApiLogger::getInstance()->logDebug("AdultCast createWithAdultSpecifics: Saving occupations: " . json_encode($data['occupations']));
$this->saveRelatedItems('occupations', $castId, $data['occupations'], 'cast_id');
}
// Adult-spezifische Daten speichern
if (isset($data['adult_specifics']) && is_array($data['adult_specifics'])) {
ApiLogger::getInstance()->logDebug("AdultCast createWithAdultSpecifics: Saving adult_specifics");
$this->saveAdultSpecifics($castId, $data['adult_specifics']);
} else {
ApiLogger::getInstance()->logDebug("AdultCast createWithAdultSpecifics: No adult_specifics found in data");
}
return $castId;
}
public function updateWithAdultSpecifics($id, $data) {
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics called with id: $id, data: " . json_encode($data));
// Set update flag for image replacement
$this->isUpdate = true;
$this->castId = $id;
// Process photo field (base64 to file path)
$data = $this->processPhotoField($data);
// Basis-Cast aktualisieren
$castData = [];
foreach (['name', 'photo', 'bio', 'birthDate', 'birthPlace'] as $field) {
if (array_key_exists($field, $data)) {
$castData[$field] = $data[$field];
}
}
if (!empty($castData)) {
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics: Updating base cast data: " . json_encode($castData));
$this->update($id, $castData);
}
// Occupations aktualisieren
if (isset($data['occupations']) && is_array($data['occupations'])) {
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics: Updating occupations: " . json_encode($data['occupations']));
$this->pdo->prepare("DELETE FROM occupations WHERE cast_id = ?")->execute([$id]);
$this->saveRelatedItems('occupations', $id, $data['occupations'], 'cast_id');
}
// Adult-spezifische Daten aktualisieren
if (isset($data['adult_specifics']) && is_array($data['adult_specifics'])) {
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics: Saving adult_specifics");
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics: adult_specifics data: " . json_encode($data['adult_specifics']));
$this->saveAdultSpecifics($id, $data['adult_specifics']);
} else {
ApiLogger::getInstance()->logDebug("AdultCast updateWithAdultSpecifics: No adult_specifics found in data");
}
return true;
}
protected function saveAdultSpecifics($castId, $specifics) {
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics called for cast_id: $castId");
ApiLogger::getInstance()->logDebug("Specifics data: " . json_encode($specifics));
// Prüfen ob bereits Eintrag existiert
$stmt = $this->pdo->prepare("SELECT * FROM adult_cast_specifics WHERE cast_id = ?");
$stmt->execute([$castId]);
$existing = $stmt->fetch();
ApiLogger::getInstance()->logDebug("Existing entry: " . ($existing ? 'yes' : 'no'));
$fields = ['bust_size', 'cup_size', 'waist_size', 'hip_size', 'height', 'weight',
'hair_color', 'eye_color', 'ethnicity', 'tattoos', 'piercings', 'measurements', 'shoe_size'];
if ($existing) {
// Update
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Existing entry found, doing UPDATE");
$updateFields = [];
$params = [];
foreach ($fields as $field) {
if (array_key_exists($field, $specifics)) {
$updateFields[] = "$field = ?";
$params[] = $specifics[$field];
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Found field $field with value: " . json_encode($specifics[$field] ?? 'null'));
}
}
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Update fields: " . implode(', ', $updateFields));
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Total update fields count: " . count($updateFields));
if (!empty($updateFields)) {
$params[] = $castId;
$query = "UPDATE adult_cast_specifics SET " . implode(', ', $updateFields) . " WHERE cast_id = ?";
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Executing query: $query");
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: With params: " . json_encode($params));
$stmt = $this->pdo->prepare($query);
try {
$stmt->execute($params);
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: UPDATE successful");
} catch (Exception $e) {
// Fehler loggen
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics Error: " . $e->getMessage());
ApiLogger::getInstance()->logDebug("Query: $query");
ApiLogger::getInstance()->logDebug("Params: " . json_encode($params));
}
} else {
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: No fields to update");
}
} else {
// Insert
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: No existing entry, doing INSERT");
$insertFields = [];
$values = [];
$params = [];
$insertFields[] = 'cast_id';
$values[] = '?';
$params[] = $castId;
foreach ($fields as $field) {
if (array_key_exists($field, $specifics)) {
$insertFields[] = $field;
$values[] = '?';
$params[] = $specifics[$field];
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Found field $field with value: " . json_encode($specifics[$field] ?? 'null'));
}
}
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Insert fields: " . implode(', ', $insertFields));
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Total fields count: " . count($insertFields));
if (count($insertFields) > 1) {
$query = "INSERT INTO adult_cast_specifics (" . implode(', ', $insertFields) . ") VALUES (" . implode(', ', $values) . ")";
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Executing query: $query");
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: With params: " . json_encode($params));
$stmt = $this->pdo->prepare($query);
try {
$stmt->execute($params);
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: INSERT successful");
} catch (Exception $e) {
// Fehler loggen
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics Error: " . $e->getMessage());
ApiLogger::getInstance()->logDebug("Query: " . $query);
ApiLogger::getInstance()->logDebug("Params: " . json_encode($params));
}
} else {
// Keine Felder zum Speichern
ApiLogger::getInstance()->logDebug("AdultCast saveAdultSpecifics: Keine Felder zum Speichern für cast_id $castId");
}
}
}
public function deleteAdultSpecifics($castId) {
$stmt = $this->pdo->prepare("DELETE FROM adult_cast_specifics WHERE cast_id = ?");
$stmt->execute([$castId]);
return $stmt->rowCount() > 0;
}
public function searchAdultActors($filters = [], $page = 1, $limit = 2000000000) {
// Adult Actors mit Specifics suchen
$query = "
SELECT cs.*,
acs.bust_size, acs.cup_size, acs.waist_size, acs.hip_size,
acs.height, acs.weight, acs.hair_color, acs.eye_color, acs.ethnicity
FROM cast_staff cs
LEFT JOIN adult_cast_specifics acs ON cs.id = acs.cast_id
WHERE acs.cast_id IS NOT NULL
";
$params = [];
if (isset($filters['search'])) {
$query .= " AND cs.name LIKE ?";
$params[] = "%" . $filters['search'] . "%";
}
if (isset($filters['ethnicity'])) {
$query .= " AND acs.ethnicity = ?";
$params[] = $filters['ethnicity'];
}
if (isset($filters['hair_color'])) {
$query .= " AND acs.hair_color = ?";
$params[] = $filters['hair_color'];
}
$query .= " ORDER BY cs.createdAt DESC";
$offset = ($page - 1) * $limit;
$query .= " LIMIT " . (int)2000000000000 . " OFFSET " . (int)$offset;
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
$items = $stmt->fetchAll();
ApiLogger::getInstance()->logDebug("AdultCast searchAdultActors: Found " . count($items) . " cast members");
foreach ($items as $item) {
ApiLogger::getInstance()->logDebug("AdultCast searchAdultActors: Cast ID {$item['id']} - {$item['name']}");
}
// Occupations und Filmography für jeden laden
foreach ($items as &$item) {
$item['occupations'] = $this->getRelatedItems('occupations', $item['id'], 'cast_id');
// Add filmography information
$item['filmography'] = $this->getMediaForCast($item['id']);
// Extract unique media types
$mediaTypes = array_unique(array_column($item['filmography'], 'category'));
$item['media_types'] = array_values($mediaTypes);
ApiLogger::getInstance()->logDebug("AdultCast searchAdultActors: Cast ID {$item['id']} has " . count($item['filmography']) . " filmography items");
}
// Total count
$countQuery = "
SELECT COUNT(*)
FROM cast_staff cs
INNER JOIN adult_cast_specifics acs ON cs.id = acs.cast_id
WHERE 1=1
";
$countParams = [];
if (isset($filters['search'])) {
$countQuery .= " AND cs.name LIKE ?";
$countParams[] = "%" . $filters['search'] . "%";
}
if (isset($filters['ethnicity'])) {
$countQuery .= " AND acs.ethnicity = ?";
$countParams[] = $filters['ethnicity'];
}
if (isset($filters['hair_color'])) {
$countQuery .= " AND acs.hair_color = ?";
$countParams[] = $filters['hair_color'];
}
$countStmt = $this->pdo->prepare($countQuery);
$countStmt->execute($countParams);
$total = $countStmt->fetchColumn();
return [
'items' => $items,
'total' => $total,
'page' => $page,
'limit' => $limit
];
}
}

100
api/models/BaseModel.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
abstract class BaseModel {
protected $pdo;
protected $table;
public function __construct($pdo) {
$this->pdo = $pdo;
}
protected function findById($id) {
$stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
protected function findAll($conditions = [], $orderBy = 'createdAt DESC', $limit = null, $offset = null) {
$query = "SELECT * FROM {$this->table} WHERE 1=1";
$params = [];
foreach ($conditions as $field => $value) {
if (is_array($value)) {
// LIKE Operator
$query .= " AND $field LIKE ?";
$params[] = $value[0];
} else {
$query .= " AND $field = ?";
$params[] = $value;
}
}
$query .= " ORDER BY $orderBy";
if ($limit) {
$query .= " LIMIT " . (int)$limit;
}
if ($offset) {
$query .= " OFFSET " . (int)$offset;
}
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
return $stmt->fetchAll();
}
protected function count($conditions = []) {
$query = "SELECT COUNT(*) FROM {$this->table} WHERE 1=1";
$params = [];
foreach ($conditions as $field => $value) {
if (is_array($value)) {
$query .= " AND $field LIKE ?";
$params[] = $value[0];
} else {
$query .= " AND $field = ?";
$params[] = $value;
}
}
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
return $stmt->fetchColumn();
}
protected function create($data) {
$fields = array_keys($data);
$placeholders = array_fill(0, count($fields), '?');
$query = "INSERT INTO {$this->table} (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $placeholders) . ")";
$stmt = $this->pdo->prepare($query);
$stmt->execute(array_values($data));
return $this->pdo->lastInsertId();
}
protected function update($id, $data) {
$fields = [];
$params = [];
foreach ($data as $field => $value) {
$fields[] = "$field = ?";
$params[] = $value;
}
$params[] = $id;
$query = "UPDATE {$this->table} SET " . implode(', ', $fields) . " WHERE id = ?";
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
return $stmt->rowCount() > 0;
}
protected function delete($id) {
$stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}

190
api/models/Cast.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
require_once __DIR__ . '/BaseModel.php';
require_once __DIR__ . '/../services/ImageHandler.php';
class Cast extends BaseModel {
protected $table = 'cast_staff';
private $imageHandler;
protected $isUpdate = false;
protected $castId = null;
public function __construct($pdo) {
parent::__construct($pdo);
$this->imageHandler = new ImageHandler();
}
public function getWithFilmography($id) {
$cast = $this->findById($id);
if (!$cast) {
return null;
}
$cast['occupations'] = $this->getRelatedItems('occupations', $id, 'cast_id');
$cast['filmography'] = $this->getMediaForCast($id);
return $cast;
}
public function getMediaForCast($castId) {
$stmt = $this->pdo->prepare("
SELECT m.id, m.title, m.year, m.poster, m.category, m.type, mc.role, mc.characterName
FROM media m
INNER JOIN media_cast mc ON m.id = mc.media_id
WHERE mc.cast_id = ?
ORDER BY m.year DESC
");
$stmt->execute([$castId]);
return $stmt->fetchAll();
}
public function search($filters = [], $page = 1, $limit = 20) {
$conditions = [];
if (isset($filters['search'])) {
$conditions['name'] = ["%" . $filters['search'] . "%"];
}
$offset = ($page - 1) * $limit;
$items = $this->findAll($conditions, 'createdAt DESC', $limit, $offset);
$total = $this->count($conditions);
// Add filmography to each cast member
foreach ($items as &$item) {
$item['filmography'] = $this->getMediaForCast($item['id']);
// Extract unique media types
$mediaTypes = array_unique(array_column($item['filmography'], 'category'));
$item['media_types'] = array_values($mediaTypes);
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'limit' => $limit
];
}
protected function processPhotoField($data) {
error_log("Cast::processPhotoField - Checking for photo field, isUpdate: " . ($this->isUpdate ? 'yes' : 'no'));
if ($this->isUpdate && $this->castId && isset($data['photo']) && !empty($data['photo'])) {
$currentCast = $this->findById($this->castId);
if ($currentCast && !empty($currentCast['photo'])) {
$oldPhoto = $currentCast['photo'];
if (strpos($oldPhoto, '/images/') === 0) {
error_log("Cast::processPhotoField - Deleting old photo: " . $oldPhoto);
$this->imageHandler->deleteImage($oldPhoto);
}
}
}
if (isset($data['photo']) && !empty($data['photo'])) {
error_log("Cast::processPhotoField - Photo found, length: " . strlen($data['photo']));
if (strpos($data['photo'], '/images/') === 0 || filter_var($data['photo'], FILTER_VALIDATE_URL)) {
error_log("Cast::processPhotoField - Photo is already a path or URL, skipping processing");
return $data;
}
$photoPath = $this->imageHandler->saveBase64Image($data['photo'], 'cast/photo');
error_log("Cast::processPhotoField - ImageHandler returned: " . ($photoPath ?: 'null'));
if ($photoPath) {
$data['photo'] = $photoPath;
error_log("Cast::processPhotoField - Photo path set to: " . $photoPath);
} else {
error_log("Cast::processPhotoField - Failed to process photo, keeping original data");
}
} else {
error_log("Cast::processPhotoField - No photo field found or empty");
}
return $data;
}
public function createWithOccupations($data) {
$name = $data['name'] ?? null;
if (!$name) {
throw new Exception('Name is required');
}
// cleanname generieren
$cleanname = generateCleanName($name);
$data = $this->processPhotoField($data);
$castData = [
'name' => $name,
'cleanname' => $cleanname,
'photo' => $data['photo'] ?? null,
'bio' => $data['bio'] ?? null,
'birthDate' => $data['birthDate'] ?? null,
'birthPlace' => $data['birthPlace'] ?? null
];
$castId = $this->create($castData);
if (isset($data['occupations']) && is_array($data['occupations'])) {
$this->saveRelatedItems('occupations', $castId, $data['occupations'], 'cast_id');
}
return $castId;
}
public function updateWithOccupations($id, $data) {
$this->isUpdate = true;
$this->castId = $id;
$data = $this->processPhotoField($data);
$castData = [];
foreach (['name', 'photo', 'bio', 'birthDate', 'birthPlace'] as $field) {
if (array_key_exists($field, $data)) {
$castData[$field] = $data[$field];
}
}
// Wenn name geändert wurde, cleanname aktualisieren
if (isset($data['name'])) {
$castData['cleanname'] = generateCleanName($data['name']);
}
if (!empty($castData)) {
$this->update($id, $castData);
}
if (isset($data['occupations']) && is_array($data['occupations'])) {
$this->pdo->prepare("DELETE FROM occupations WHERE cast_id = ?")->execute([$id]);
$this->saveRelatedItems('occupations', $id, $data['occupations'], 'cast_id');
}
return true;
}
public function findByCleanName($cleanname) {
$stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE cleanname = ?");
$stmt->execute([$cleanname]);
return $stmt->fetch();
}
protected function getRelatedItems($table, $id, $fkColumn = 'media_id') {
$stmt = $this->pdo->prepare("SELECT * FROM $table WHERE $fkColumn = ?");
$stmt->execute([$id]);
$items = $stmt->fetchAll();
$result = [];
foreach ($items as $item) {
$result[] = $fkColumn === 'cast_id' ? $item['occupation'] : $item['genre'];
}
return $result;
}
protected function saveRelatedItems($table, $id, $items, $fkColumn = 'media_id') {
$valueColumn = $fkColumn === 'cast_id' ? 'occupation' : 'genre';
$stmt = $this->pdo->prepare("INSERT INTO $table ($fkColumn, $valueColumn) VALUES (?, ?)");
foreach ($items as $item) {
$stmt->execute([$id, $item]);
}
}
}

23
api/models/Console.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/MediaType.php';
class Console extends MediaType {
protected function getType() {
return 'Console';
}
protected function getTypeSpecificFields() {
return [];
}
protected function validateTypeSpecificFields($data) {
// Console spezifische Validierung
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Consoles suchen
$filters['type'] = 'Console';
return parent::search($filters, $page, $limit);
}
}

469
api/models/Game.php Normal file
View File

@@ -0,0 +1,469 @@
<?php
require_once __DIR__ . '/MediaType.php';
require_once __DIR__ . '/../services/ImageHandler.php';
class Game extends MediaType {
private $imageHandler;
private $isUpdate = false;
private $mediaId = null;
public function __construct($pdo) {
parent::__construct($pdo);
$this->imageHandler = new ImageHandler();
}
protected function getType() {
return 'Game';
}
protected function getTypeSpecificFields() {
return [];
}
protected function validateTypeSpecificFields($data) {
// Game spezifische Validierung
if (isset($data['hasIcon'])) {
$data['hasIcon'] = is_numeric($data['hasIcon']) ? (int)$data['hasIcon'] : 0;
}
if (isset($data['hasCover'])) {
$data['hasCover'] = is_numeric($data['hasCover']) ? (int)$data['hasCover'] : 0;
}
if (isset($data['hasBackground'])) {
$data['hasBackground'] = is_numeric($data['hasBackground']) ? (int)$data['hasBackground'] : 0;
}
if (isset($data['playtime']) && !is_numeric($data['playtime'])) {
$data['playtime'] = is_numeric($data['playtime']) ? (int)$data['playtime'] : 0;
}
if (isset($data['isInstalled'])) {
$data['isInstalled'] = is_numeric($data['isInstalled']) ? (int)$data['isInstalled'] : 0;
}
if (isset($data['hidden'])) {
$data['hidden'] = is_numeric($data['hidden']) ? (int)$data['hidden'] : 0;
}
if (isset($data['favorite'])) {
$data['favorite'] = is_numeric($data['favorite']) ? (int)$data['favorite'] : 0;
}
return $data;
}
/**
* Process poster field - convert base64 to file path
*/
protected function processImageField($data, $type) {
if ($this->isUpdate && $this->mediaId && isset($data[$type]) && !empty($data[$type])) {
$currentMedia = $this->findById($this->mediaId);
if ($currentMedia && !empty($currentMedia[$type])) {
$oldPoster = $currentMedia[$type];
if (strpos($oldPoster, '/images/') === 0) {
$this->imageHandler->deleteImage($oldPoster);
}
}
}
if (isset($data[$type]) && !empty($data[$type])) {
if (strpos($data[$type], '/images/') === 0 || filter_var($data[$type], FILTER_VALIDATE_URL)) {
return $data;
}
$posterPath = $this->imageHandler->saveBase64Image($data[$type], 'games/'.$type.'/');
if ($posterPath) {
$data[$type] = $posterPath;
}
}
return $data;
}
public function createWithRelations($data) {
// Typ setzen
$data['type'] = 'Game';
// Typ-spezifische Validierung
$data = $this->validateTypeSpecificFields($data);
// Poster verarbeiten (Base64 zu Dateipfad)
$data = $this->processImageField($data, 'poster');
$data = $this->processImageField($data, 'banner');
$data = $this->processImageField($data, 'icon');
// Basis-Media erstellen
$mediaId = parent::createWithRelations($data);
// Media-Games Eintrag erstellen
$gameData = [
'media_id' => $mediaId,
'sortingName' => $data['sortingName'] ?? null,
'notes' => $data['notes'] ?? null,
'completionStatus' => $data['completionStatus'] ?? null,
'source' => $data['source'] ?? null,
'gameId' => $data['gameId'] ?? null,
'pluginId' => $data['pluginId'] ?? null,
'isInstalled' => $data['isInstalled'] ?? false,
'installDirectory' => $data['installDirectory'] ?? null,
'installSize' => $data['installSize'] ?? 0,
'hidden' => $data['hidden'] ?? false,
'favorite' => $data['favorite'] ?? false,
'playCount' => $data['playCount'] ?? 0,
'lastActivity' => $data['lastActivity'] ?? null,
'added' => null,
'modified' => null,
'communityScore' => $data['communityScore'] ?? 0,
'criticScore' => $data['criticScore'] ?? 0,
'userScore' => $data['userScore'] ?? 0,
'hasIcon' => $data['hasIcon'] ?? 0,
'hasCover' => $data['hasCover'] ?? 0,
'hasBackground' => $data['hasBackground'] ?? 0,
'version' => $data['version'] ?? null,
'playtime' => $data['playtime'] ?? 0
];
$mediaGameId = $this->createMediaGame($gameData);
// Relationen speichern
if (isset($data['achievements']) && is_array($data['achievements'])) {
$this->saveAchievements($mediaGameId, $data['achievements']);
}
if (isset($data['categories']) && is_array($data['categories'])) {
$this->saveGameRelation('game_categories', $mediaGameId, $data['categories'], 'category');
}
if (isset($data['features']) && is_array($data['features'])) {
$this->saveGameRelation('game_features', $mediaGameId, $data['features'], 'feature');
}
if (isset($data['platforms']) && is_array($data['platforms'])) {
$this->saveGameRelationWithConsole('game_platforms', $mediaGameId, $data['platforms'], 'platform');
}
if (isset($data['developers']) && is_array($data['developers'])) {
$this->saveGameRelation('game_developers', $mediaGameId, $data['developers'], 'developer');
}
if (isset($data['publishers']) && is_array($data['publishers'])) {
$this->saveGameRelation('game_publishers', $mediaGameId, $data['publishers'], 'publisher');
}
if (isset($data['series']) && is_array($data['series'])) {
$this->saveGameRelation('game_series', $mediaGameId, $data['series'], 'series');
}
if (isset($data['ageRatings']) && is_array($data['ageRatings'])) {
$this->saveGameRelation('game_age_ratings', $mediaGameId, $data['ageRatings'], 'age_rating');
}
if (isset($data['regions']) && is_array($data['regions'])) {
$this->saveGameRelation('game_regions', $mediaGameId, $data['regions'], 'region');
}
if (isset($data['links']) && is_array($data['links'])) {
$this->saveLinks($mediaGameId, $data['links']);
}
return $mediaId;
}
public function updateWithRelations($id, $data) {
// Set update flag and mediaId for image replacement
$this->isUpdate = true;
$this->mediaId = $id;
// Typ-spezifische Validierung
$this->validateTypeSpecificFields($data);
// Poster verarbeiten (Base64 zu Dateipfad)
$data = $this->processImageField($data, 'poster');
$data = $this->processImageField($data, 'banner');
$data = $this->processImageField($data, 'icon');
// Basis-Media aktualisieren
parent::updateWithRelations($id, $data);
// Media-Games Eintrag aktualisieren
$gameData = [];
$gameFields = ['sortingName', 'notes', 'completionStatus', 'source', 'gameId', 'pluginId',
'isInstalled', 'installDirectory', 'installSize', 'hidden', 'favorite',
'playCount', 'lastActivity', 'added', 'modified', 'communityScore',
'criticScore', 'userScore', 'hasIcon', 'hasCover', 'hasBackground',
'version', 'playtime'];
foreach ($gameFields as $field) {
if (array_key_exists($field, $data)) {
$gameData[$field] = $data[$field];
}
}
if (!empty($gameData)) {
$this->updateMediaGame($id, $gameData);
}
// Media-Games ID abrufen
$mediaGameId = $this->getMediaGameId($id);
if ($mediaGameId) {
// Relationen aktualisieren
if (isset($data['achievements']) && is_array($data['achievements'])) {
$this->pdo->prepare("DELETE FROM achievements WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveAchievements($mediaGameId, $data['achievements']);
}
if (isset($data['categories']) && is_array($data['categories'])) {
$this->pdo->prepare("DELETE FROM game_categories WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_categories', $mediaGameId, $data['categories'], 'category');
}
if (isset($data['features']) && is_array($data['features'])) {
$this->pdo->prepare("DELETE FROM game_features WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_features', $mediaGameId, $data['features'], 'feature');
}
if (isset($data['platforms']) && is_array($data['platforms'])) {
$this->pdo->prepare("DELETE FROM game_platforms WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelationWithConsole('game_platforms', $mediaGameId, $data['platforms'], 'platform');
}
if (isset($data['developers']) && is_array($data['developers'])) {
$this->pdo->prepare("DELETE FROM game_developers WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_developers', $mediaGameId, $data['developers'], 'developer');
}
if (isset($data['publishers']) && is_array($data['publishers'])) {
$this->pdo->prepare("DELETE FROM game_publishers WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_publishers', $mediaGameId, $data['publishers'], 'publisher');
}
if (isset($data['series']) && is_array($data['series'])) {
$this->pdo->prepare("DELETE FROM game_series WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_series', $mediaGameId, $data['series'], 'series');
}
if (isset($data['ageRatings']) && is_array($data['ageRatings'])) {
$this->pdo->prepare("DELETE FROM game_age_ratings WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_age_ratings', $mediaGameId, $data['ageRatings'], 'age_rating');
}
if (isset($data['regions']) && is_array($data['regions'])) {
$this->pdo->prepare("DELETE FROM game_regions WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveGameRelation('game_regions', $mediaGameId, $data['regions'], 'region');
}
if (isset($data['links']) && is_array($data['links'])) {
$this->pdo->prepare("DELETE FROM game_links WHERE media_game_id = ?")->execute([$mediaGameId]);
$this->saveLinks($mediaGameId, $data['links']);
}
}
return true;
}
public function getWithGameInfo($id) {
$media = parent::getWithRelations($id);
if (!$media) {
return null;
}
// Media-Games Daten abrufen
$mediaGameId = $this->getMediaGameId($id);
if ($mediaGameId) {
$gameInfo = $this->getMediaGameData($mediaGameId);
$media = array_merge($media, $gameInfo);
// Relationen abrufen
$media['achievements'] = $this->getAchievements($mediaGameId);
$media['categories'] = $this->getGameRelation('game_categories', $mediaGameId, 'category');
$media['features'] = $this->getGameRelation('game_features', $mediaGameId, 'feature');
$media['platforms'] = $this->getGameRelation('game_platforms', $mediaGameId, 'platform');
$media['developers'] = $this->getGameRelation('game_developers', $mediaGameId, 'developer');
$media['publishers'] = $this->getGameRelation('game_publishers', $mediaGameId, 'publisher');
$media['series'] = $this->getGameRelation('game_series', $mediaGameId, 'series');
$media['ageRatings'] = $this->getGameRelation('game_age_ratings', $mediaGameId, 'age_rating');
$media['regions'] = $this->getGameRelation('game_regions', $mediaGameId, 'region');
$media['links'] = $this->getLinks($mediaGameId);
}
return $media;
}
public function getGameInfoForList($mediaId) {
// Media-Games Daten abrufen (ohne vollständige Relationen für Performance)
$mediaGameId = $this->getMediaGameId($mediaId);
if (!$mediaGameId) {
return null;
}
$gameInfo = $this->getMediaGameData($mediaGameId);
// Nur wichtige Relationen für Listenansicht laden
$gameInfo['categories'] = $this->getGameRelation('game_categories', $mediaGameId, 'category');
$gameInfo['platforms'] = $this->getGameRelation('game_platforms', $mediaGameId, 'platform');
$gameInfo['developers'] = $this->getGameRelation('game_developers', $mediaGameId, 'developer');
return $gameInfo;
}
public static function interpolateQuery($query, $params) {
$keys = array();
# build a regular expression for each parameter
foreach ($params as $key => $value) {
if (is_string($key)) {
$keys[] = '/:'.$key.'/';
} else {
$keys[] = '/[?]/';
}
}
$query = preg_replace($keys, $params, $query, 1, $count);
#trigger_error('replaced '.$count.' keys');
return $query;
}
protected function createMediaGame($data) {
$stmt = $this->pdo->prepare("
INSERT INTO media_games (media_id, sortingName, notes, completionStatus, source, gameId, pluginId,
isInstalled, installDirectory, installSize, hidden, favorite, playCount,
lastActivity, added, modified, communityScore, criticScore, userScore,
hasIcon, hasCover, hasBackground, version, playtime)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$data['media_id'],
$data['sortingName'],
$data['notes'],
$data['completionStatus'],
$data['source'],
$data['gameId'],
$data['pluginId'],
$data['isInstalled'],
$data['installDirectory'],
$data['installSize'],
$data['hidden'],
$data['favorite'],
$data['playCount'],
$data['lastActivity'],
$data['added'],
$data['modified'],
$data['communityScore'],
$data['criticScore'],
$data['userScore'],
$data['hasIcon'],
$data['hasCover'],
$data['hasBackground'],
$data['version'],
$data['playtime']
]);
return $this->pdo->lastInsertId();
}
protected function updateMediaGame($mediaId, $data) {
$setClause = [];
$params = [];
foreach ($data as $key => $value) {
$setClause[] = "$key = ?";
$params[] = $value;
}
$params[] = $mediaId;
$stmt = $this->pdo->prepare("
UPDATE media_games SET " . implode(', ', $setClause) . " WHERE media_id = ?
");
$stmt->execute($params);
}
protected function getMediaGameId($mediaId) {
$stmt = $this->pdo->prepare("SELECT id FROM media_games WHERE media_id = ?");
$stmt->execute([$mediaId]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
protected function getMediaGameData($mediaGameId) {
$stmt = $this->pdo->prepare("SELECT * FROM media_games WHERE id = ?");
$stmt->execute([$mediaGameId]);
$data = $stmt->fetch();
if ($data) {
unset($data['id'], $data['media_id']);
}
return $data ?: [];
}
protected function saveAchievements($mediaGameId, $achievements) {
$stmt = $this->pdo->prepare("
INSERT INTO achievements (media_game_id, name, description, icon, unlocked, unlocked_date)
VALUES (?, ?, ?, ?, ?, ?)
");
foreach ($achievements as $achievement) {
$unlockedDate = null;
if (isset($achievement['unlocked']) && $achievement['unlocked'] && isset($achievement['unlocked_date'])) {
$unlockedDate = $achievement['unlocked_date'];
}
$stmt->execute([
$mediaGameId,
$achievement['name'] ?? null,
$achievement['description'] ?? null,
$achievement['icon'] ?? null,
$achievement['unlocked'] ?? false,
$unlockedDate
]);
}
}
protected function getAchievements($mediaGameId) {
$stmt = $this->pdo->prepare("SELECT * FROM achievements WHERE media_game_id = ?");
$stmt->execute([$mediaGameId]);
return $stmt->fetchAll();
}
protected function saveGameRelation($table, $mediaGameId, $items, $field) {
$stmt = $this->pdo->prepare("INSERT INTO $table (media_game_id, $field) VALUES (?, ?)");
foreach ($items as $item) {
$stmt->execute([$mediaGameId, $item]);
}
}
protected function consoleExists($name) {
$stmt = $this->pdo->prepare("SELECT id FROM media WHERE type = 'Console' AND title = ?");
$stmt->execute([$name]);
return $stmt->fetch() !== false;
}
protected function createConsole($name) {
$stmt = $this->pdo->prepare("
INSERT INTO media (title, cleanname, type, createdAt, updatedAt)
VALUES (?, ?, 'Console', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
");
$stmt->execute([$name, strtolower(str_replace(' ', '-', $name))]);
return $this->pdo->lastInsertId();
}
protected function saveGameRelationWithConsole($table, $mediaGameId, $items, $field) {
$stmt = $this->pdo->prepare("INSERT INTO $table (media_game_id, $field) VALUES (?, ?)");
foreach ($items as $item) {
// Check if console exists, create if not
if ($field === 'platform' && !$this->consoleExists($item)) {
$this->createConsole($item);
}
$stmt->execute([$mediaGameId, $item]);
}
}
protected function getGameRelation($table, $mediaGameId, $field) {
$stmt = $this->pdo->prepare("SELECT $field FROM $table WHERE media_game_id = ?");
$stmt->execute([$mediaGameId]);
$items = $stmt->fetchAll();
return array_map(function($item) use ($field) {
return $item[$field];
}, $items);
}
protected function saveLinks($mediaGameId, $links) {
$stmt = $this->pdo->prepare("INSERT INTO game_links (media_game_id, name, url) VALUES (?, ?, ?)");
foreach ($links as $link) {
$stmt->execute([
$mediaGameId,
$link['name'] ?? null,
$link['url'] ?? null
]);
}
}
protected function getLinks($mediaGameId) {
$stmt = $this->pdo->prepare("SELECT name, url FROM game_links WHERE media_game_id = ?");
$stmt->execute([$mediaGameId]);
return $stmt->fetchAll();
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Games suchen
$filters['type'] = 'Game';
return parent::search($filters, $page, $limit);
}
}

291
api/models/Media.php Normal file
View File

@@ -0,0 +1,291 @@
<?php
require_once __DIR__ . '/BaseModel.php';
class Media extends BaseModel {
protected $table = 'media';
public function getBase($id) {
return $this->findById($id);
}
public function getWithRelations($id) {
$media = $this->findById($id);
if (!$media) {
return null;
}
$media['genres'] = $this->getRelatedItems('genres', $id);
$media['tags'] = $this->getRelatedItems('tags', $id);
$media['studios'] = $this->getRelatedItems('studios', $id);
$media['staff'] = $this->getCastForMedia($id);
return $media;
}
public function search($filters = [], $page = 1, $limit = 20) {
$conditions = [];
if (isset($filters['category'])) {
$conditions['category'] = $filters['category'];
}
if (isset($filters['type'])) {
$conditions['type'] = $filters['type'];
}
if (isset($filters['search'])) {
$searchTerm = "%" . $filters['search'] . "%";
$conditions['title'] = [$searchTerm];
// OR Bedingung für description wird separat behandelt
}
$offset = ($page - 1) * $limit;
if (isset($filters['search'])) {
// Komplexe Suche mit OR
$query = "SELECT * FROM {$this->table} WHERE 1=1";
$params = [];
if (isset($filters['category'])) {
$query .= " AND category = ?";
$params[] = $filters['category'];
}
if (isset($filters['type'])) {
$query .= " AND type = ?";
$params[] = $filters['type'];
}
$query .= " AND (title LIKE ? OR description LIKE ?)";
$searchTerm = "%" . $filters['search'] . "%";
$params[] = $searchTerm;
$params[] = $searchTerm;
$query .= " ORDER BY createdAt DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset;
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
$items = $stmt->fetchAll();
// Cast-Mitglieder für jedes Medium laden
foreach ($items as &$item) {
$item['staff'] = $this->getCastForMedia($item['id']);
}
// Count Query
$countQuery = "SELECT COUNT(*) FROM {$this->table} WHERE 1=1";
$countParams = [];
if (isset($filters['category'])) {
$countQuery .= " AND category = ?";
$countParams[] = $filters['category'];
}
if (isset($filters['type'])) {
$countQuery .= " AND type = ?";
$countParams[] = $filters['type'];
}
$countQuery .= " AND (title LIKE ? OR description LIKE ?)";
$countParams[] = $searchTerm;
$countParams[] = $searchTerm;
$countStmt = $this->pdo->prepare($countQuery);
$countStmt->execute($countParams);
$total = $countStmt->fetchColumn();
} else {
$items = $this->findAll($conditions, 'createdAt DESC', $limit, $offset);
$total = $this->count($conditions);
// Cast-Mitglieder für jedes Medium laden
foreach ($items as &$item) {
$item['staff'] = $this->getCastForMedia($item['id']);
}
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'limit' => $limit,
'totalPages' => ceil($total / $limit)
];
}
public function createWithRelations($data) {
$title = $data['title'] ?? null;
if (!$title) {
throw new Exception('Title is required');
}
// cleanname generieren
$cleanname = generateCleanName($title);
// Basis-Media-Daten extrahieren
$mediaData = [
'title' => $title,
'cleanname' => $cleanname,
'year' => $data['year'] ?? null,
'poster' => $data['poster'] ?? null,
'banner' => $data['banner'] ?? null,
'description' => $data['description'] ?? null,
'rating' => $data['rating'] ?? null,
'category' => $data['category'] ?? null,
'type' => $data['type'] ?? null,
'status' => $data['status'] ?? null,
'aspectRatio' => $data['aspectRatio'] ?? null,
'runtime' => $data['runtime'] ?? null,
'director' => $data['director'] ?? null,
'writer' => $data['writer'] ?? null,
'source' => $data['source'] ?? null,
'releaseDate' => $data['releaseDate'] ?? null
];
$mediaId = $this->create($mediaData);
// Relationen speichern
if (isset($data['genres']) && is_array($data['genres'])) {
$this->saveRelatedItems('genres', $mediaId, $data['genres']);
}
if (isset($data['tags']) && is_array($data['tags'])) {
$this->saveRelatedItems('tags', $mediaId, $data['tags']);
}
if (isset($data['studios']) && is_array($data['studios'])) {
$this->saveRelatedItems('studios', $mediaId, $data['studios']);
}
if (isset($data['staff']) && is_array($data['staff'])) {
$this->saveCastAssignments($mediaId, $data['staff']);
}
return $mediaId;
}
public function updateWithRelations($id, $data) {
$mediaData = [];
foreach (['title', 'year', 'poster', 'banner', 'description', 'rating', 'category', 'type', 'status', 'aspectRatio', 'runtime', 'director', 'writer', 'releaseDate', 'source'] as $field) {
if (array_key_exists($field, $data)) {
$mediaData[$field] = $data[$field];
}
}
// Wenn title geändert wurde, cleanname aktualisieren
if (isset($data['title'])) {
$mediaData['cleanname'] = generateCleanName($data['title']);
}
if (!empty($mediaData)) {
$this->update($id, $mediaData);
}
// Relationen aktualisieren
if (isset($data['genres']) && is_array($data['genres'])) {
$this->pdo->prepare("DELETE FROM genres WHERE media_id = ?")->execute([$id]);
$this->saveRelatedItems('genres', $id, $data['genres']);
}
if (isset($data['tags']) && is_array($data['tags'])) {
$this->pdo->prepare("DELETE FROM tags WHERE media_id = ?")->execute([$id]);
$this->saveRelatedItems('tags', $id, $data['tags']);
}
if (isset($data['studios']) && is_array($data['studios'])) {
$this->pdo->prepare("DELETE FROM studios WHERE media_id = ?")->execute([$id]);
$this->saveRelatedItems('studios', $id, $data['studios']);
}
if (isset($data['staff']) && is_array($data['staff'])) {
$this->pdo->prepare("DELETE FROM media_cast WHERE media_id = ?")->execute([$id]);
$this->saveCastAssignments($id, $data['staff']);
}
return true;
}
public function findByCleanName($cleanname) {
$stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE cleanname = ?");
$stmt->execute([$cleanname]);
return $stmt->fetch();
}
protected function saveRelatedItems($table, $id, $items, $fkColumn = 'media_id') {
$valueColumn = $table === 'genres' ? 'genre' : ($table === 'tags' ? 'tag' : ($table === 'occupations' ? 'occupation' : 'studio'));
$stmt = $this->pdo->prepare("INSERT INTO $table ($fkColumn, $valueColumn) VALUES (?, ?)");
foreach ($items as $item) {
$stmt->execute([$id, $item]);
}
}
protected function getCastForMedia($mediaId) {
$stmt = $this->pdo->prepare("
SELECT cs.*, mc.role, mc.characterName, mc.characterImage
FROM cast_staff cs
INNER JOIN media_cast mc ON cs.id = mc.cast_id
WHERE mc.media_id = ?
");
$stmt->execute([$mediaId]);
$cast = $stmt->fetchAll();
foreach ($cast as &$member) {
$member['occupations'] = $this->getRelatedItems('occupations', $member['id'], 'cast_id');
}
return $cast;
}
protected function getRelatedItems($table, $id, $fkColumn = 'media_id') {
$stmt = $this->pdo->prepare("SELECT * FROM $table WHERE $fkColumn = ?");
$stmt->execute([$id]);
$items = $stmt->fetchAll();
$result = [];
foreach ($items as $item) {
if ($fkColumn === 'cast_id') {
$result[] = $item['occupation'];
} else {
$result[] = $table === 'genres' ? $item['genre'] : ($table === 'tags' ? $item['tag'] : $item['studio']);
}
}
return $result;
}
protected function saveCastAssignments($mediaId, $castData) {
foreach ($castData as $member) {
$castId = null;
if (isset($member['id']) && $member['id']) {
$castId = $member['id'];
} elseif (isset($member['name'])) {
$stmt = $this->pdo->prepare("SELECT id FROM cast_staff WHERE name = ? LIMIT 1");
$stmt->execute([$member['name']]);
$existing = $stmt->fetch();
if ($existing) {
$castId = $existing['id'];
} else {
$cleanname = generateCleanName($member['name']);
$stmt = $this->pdo->prepare("
INSERT INTO cast_staff (name, cleanname, photo, bio, birthDate, birthPlace)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$member['name'] ?? null,
$cleanname,
$member['photo'] ?? null,
$member['bio'] ?? null,
$member['birthDate'] ?? null,
$member['birthPlace'] ?? null
]);
$castId = $this->pdo->lastInsertId();
if (isset($member['occupations']) && is_array($member['occupations'])) {
$this->saveRelatedItems('occupations', $castId, $member['occupations'], 'cast_id');
}
}
}
if ($castId) {
$stmt = $this->pdo->prepare("
INSERT INTO media_cast (media_id, cast_id, role, characterName, characterImage)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([
$mediaId,
$castId,
$member['role'] ?? null,
$member['characterName'] ?? null,
$member['characterImage'] ?? null
]);
}
}
}
}

39
api/models/MediaType.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
require_once __DIR__ . '/Media.php';
abstract class MediaType extends Media {
protected $type;
public function __construct($pdo) {
parent::__construct($pdo);
$this->type = $this->getType();
}
abstract protected function getType();
abstract protected function validateTypeSpecificFields($data);
abstract protected function getTypeSpecificFields();
public function createWithRelations($data) {
// Typ setzen
$data['type'] = $this->type;
// Typ-spezifische Validierung
$this->validateTypeSpecificFields($data);
return parent::createWithRelations($data);
}
public function updateWithRelations($id, $data) {
// Typ-spezifische Validierung
$this->validateTypeSpecificFields($data);
return parent::updateWithRelations($id, $data);
}
protected function getRequiredFields() {
return [];
}
}

88
api/models/Movie.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
require_once __DIR__ . '/MediaType.php';
require_once __DIR__ . '/../services/ImageHandler.php';
class Movie extends MediaType {
private $imageHandler;
private $isUpdate = false;
private $mediaId = null;
public function __construct($pdo) {
parent::__construct($pdo);
$this->imageHandler = new ImageHandler();
}
protected function getType() {
return 'Movie';
}
protected function getTypeSpecificFields() {
return ['runtime', 'director', 'writer'];
}
protected function validateTypeSpecificFields($data) {
// Movies sollten bestimmte Felder haben
if (isset($data['runtime']) && !is_numeric($data['runtime'])) {
throw new Exception('Runtime must be a number');
}
}
protected function processPosterField($data) {
error_log("Movie::processPosterField - Checking for poster field, isUpdate: " . ($this->isUpdate ? 'yes' : 'no'));
// If this is an update and poster is being changed, delete old image
if ($this->isUpdate && $this->mediaId && isset($data['poster']) && !empty($data['poster'])) {
$currentMedia = $this->findById($this->mediaId);
if ($currentMedia && !empty($currentMedia['poster'])) {
$oldPoster = $currentMedia['poster'];
if (strpos($oldPoster, '/images/') === 0) {
error_log("Movie::processPosterField - Deleting old poster: " . $oldPoster);
$this->imageHandler->deleteImage($oldPoster);
}
}
}
if (isset($data['poster']) && !empty($data['poster'])) {
error_log("Movie::processPosterField - Poster found, length: " . strlen($data['poster']));
if (strpos($data['poster'], '/images/') === 0 || filter_var($data['poster'], FILTER_VALIDATE_URL)) {
error_log("Movie::processPosterField - Poster is already a path or URL, skipping processing");
return $data;
}
$posterPath = $this->imageHandler->saveBase64Image($data['poster'], 'movies/poster');
error_log("Movie::processPosterField - ImageHandler returned: " . ($posterPath ?: 'null'));
if ($posterPath) {
$data['poster'] = $posterPath;
error_log("Movie::processPosterField - Poster path set to: " . $posterPath);
} else {
error_log("Movie::processPosterField - Failed to process poster, keeping original data");
}
} else {
error_log("Movie::processPosterField - No poster field found or empty");
}
return $data;
}
public function createWithRelations($data) {
$data['type'] = 'Movie';
$data = $this->validateTypeSpecificFields($data);
$data = $this->processPosterField($data);
return parent::createWithRelations($data);
}
public function updateWithRelations($id, $data) {
$this->isUpdate = true;
$this->mediaId = $id;
$this->validateTypeSpecificFields($data);
$data = $this->processPosterField($data);
parent::updateWithRelations($id, $data);
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Movies suchen
$filters['type'] = 'Movie';
return parent::search($filters, $page, $limit);
}
}

112
api/models/Music.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
require_once __DIR__ . '/MediaType.php';
class Music extends MediaType {
protected function getType() {
return 'Album';
}
protected function getTypeSpecificFields() {
return ['artist', 'album_type', 'release_date'];
}
protected function validateTypeSpecificFields($data) {
// Music spezifische Validierung
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Music suchen
$filters['type'] = 'Album';
return parent::search($filters, $page, $limit);
}
public function getWithTracks($id) {
$media = $this->getWithRelations($id);
if (!$media) {
return null;
}
$media['tracks'] = $this->getTracks($id);
return $media;
}
public function getTracks($mediaId) {
$stmt = $this->pdo->prepare("
SELECT * FROM tracks WHERE media_id = ? ORDER BY track_number
");
$stmt->execute([$mediaId]);
return $stmt->fetchAll();
}
public function addTrack($mediaId, $trackData) {
$stmt = $this->pdo->prepare("
INSERT INTO tracks (media_id, track_number, title, artist)
VALUES (?, ?, ?, ?)
");
$stmt->execute([
$mediaId,
$trackData['track_number'] ?? null,
$trackData['title'] ?? null,
//$trackData['duration'] ?? null,
$trackData['artist'] ?? null
]);
return $this->pdo->lastInsertId();
}
public function updateTrack($trackId, $trackData) {
$fields = [];
$params = [];
foreach (['track_number', 'title', 'artist'] as $field) {
if (array_key_exists($field, $trackData)) {
$fields[] = "$field = ?";
$params[] = $trackData[$field];
}
}
if (!empty($fields)) {
$params[] = $trackId;
$stmt = $this->pdo->prepare("UPDATE tracks SET " . implode(', ', $fields) . " WHERE id = ?");
$stmt->execute($params);
return true;
}
return false;
}
public function deleteTrack($trackId) {
$stmt = $this->pdo->prepare("DELETE FROM tracks WHERE id = ?");
$stmt->execute([$trackId]);
return $stmt->rowCount() > 0;
}
public function createWithRelations($data) {
$mediaId = parent::createWithRelations($data);
// Tracks speichern
if (isset($data['tracks']) && is_array($data['tracks'])) {
foreach ($data['tracks'] as $track) {
$this->addTrack($mediaId, $track);
}
}
return $mediaId;
}
public function updateWithRelations($id, $data) {
parent::updateWithRelations($id, $data);
// Tracks aktualisieren
if (isset($data['tracks']) && is_array($data['tracks'])) {
// Alle existierenden Tracks löschen
$this->pdo->prepare("DELETE FROM tracks WHERE media_id = ?")->execute([$id]);
// Neue Tracks hinzufügen
foreach ($data['tracks'] as $track) {
$this->addTrack($id, $track);
}
}
return true;
}
}

140
api/models/Series.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
require_once __DIR__ . '/MediaType.php';
class Series extends MediaType {
protected function getType() {
return 'TV';
}
protected function getTypeSpecificFields() {
return ['runtime', 'director', 'writer'];
}
protected function validateTypeSpecificFields($data) {
// Series Validierung
if (isset($data['runtime']) && !is_numeric($data['runtime'])) {
throw new Exception('Runtime must be a number');
}
}
public function search($filters = [], $page = 1, $limit = 20) {
// Nur Series suchen
$filters['type'] = 'TV';
return parent::search($filters, $page, $limit);
}
public function getWithEpisodes($id) {
$media = $this->getWithRelations($id);
if (!$media) {
return null;
}
$media['episodes'] = $this->getEpisodes($id);
$media['seasons'] = $this->getSeasons($id);
return $media;
}
public function getEpisodes($mediaId, $season = null) {
$query = "SELECT * FROM episodes WHERE media_id = ?";
$params = [$mediaId];
if ($season !== null) {
$query .= " AND season = ?";
$params[] = $season;
}
$query .= " ORDER BY season, episode_number";
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function getSeasons($mediaId) {
$stmt = $this->pdo->prepare("
SELECT DISTINCT season, COUNT(*) as episode_count,
MIN(air_date) as first_air_date, MAX(air_date) as last_air_date
FROM episodes
WHERE media_id = ?
GROUP BY season
ORDER BY season
");
$stmt->execute([$mediaId]);
return $stmt->fetchAll();
}
public function addEpisode($mediaId, $episodeData) {
$stmt = $this->pdo->prepare("
INSERT INTO episodes (media_id, season, episode_number, title, description, air_date, duration, thumbnail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$mediaId,
$episodeData['season'] ?? 1,
$episodeData['episode_number'] ?? null,
$episodeData['title'] ?? null,
$episodeData['description'] ?? null,
$episodeData['air_date'] ?? null,
$episodeData['duration'] ?? null,
$episodeData['thumbnail'] ?? null
]);
return $this->pdo->lastInsertId();
}
public function updateEpisode($episodeId, $episodeData) {
$fields = [];
$params = [];
foreach (['season', 'episode_number', 'title', 'description', 'air_date', 'duration', 'thumbnail'] as $field) {
if (array_key_exists($field, $episodeData)) {
$fields[] = "$field = ?";
$params[] = $episodeData[$field];
}
}
if (!empty($fields)) {
$params[] = $episodeId;
$stmt = $this->pdo->prepare("UPDATE episodes SET " . implode(', ', $fields) . " WHERE id = ?");
$stmt->execute($params);
return true;
}
return false;
}
public function deleteEpisode($episodeId) {
$stmt = $this->pdo->prepare("DELETE FROM episodes WHERE id = ?");
$stmt->execute([$episodeId]);
return $stmt->rowCount() > 0;
}
public function createWithRelations($data) {
$mediaId = parent::createWithRelations($data);
// Episoden speichern
if (isset($data['episodes']) && is_array($data['episodes'])) {
foreach ($data['episodes'] as $episode) {
$this->addEpisode($mediaId, $episode);
}
}
return $mediaId;
}
public function updateWithRelations($id, $data) {
parent::updateWithRelations($id, $data);
// Episoden aktualisieren
if (isset($data['episodes']) && is_array($data['episodes'])) {
// Alle existierenden Episoden löschen
$this->pdo->prepare("DELETE FROM episodes WHERE media_id = ?")->execute([$id]);
// Neue Episoden hinzufügen
foreach ($data['episodes'] as $episode) {
$this->addEpisode($id, $episode);
}
}
return true;
}
}

82
api/models/Settings.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/BaseModel.php';
class Settings extends BaseModel {
protected $table = 'settings';
public function __construct($pdo) {
parent::__construct($pdo);
}
public function getSettings() {
$stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = 1");
$stmt->execute();
$settings = $stmt->fetch();
if ($settings) {
// Decode enabled_categories from JSON
$settings['enabled_categories'] = $settings['enabled_categories'] ? json_decode($settings['enabled_categories'], true) : [];
// Convert boolean fields from tinyint to boolean
$settings['show_adult_content'] = (bool)$settings['show_adult_content'];
$settings['auto_play_trailers'] = (bool)$settings['auto_play_trailers'];
}
return $settings;
}
public function updateSettings($data) {
$updateData = [];
if (isset($data['enabled_categories']) && is_array($data['enabled_categories'])) {
$updateData['enabled_categories'] = json_encode($data['enabled_categories']);
}
if (isset($data['items_per_page'])) {
$updateData['items_per_page'] = (int)$data['items_per_page'];
}
if (isset($data['default_view'])) {
$updateData['default_view'] = $data['default_view'];
}
if (isset($data['show_adult_content'])) {
$updateData['show_adult_content'] = $data['show_adult_content'] ? 1 : 0;
}
if (isset($data['auto_play_trailers'])) {
$updateData['auto_play_trailers'] = $data['auto_play_trailers'] ? 1 : 0;
}
if (isset($data['language'])) {
$updateData['language'] = $data['language'];
}
if (isset($data['theme'])) {
$updateData['theme'] = $data['theme'];
}
// Check if settings row exists
$existing = $this->findById(1);
if ($existing) {
$this->update(1, $updateData);
return $this->getSettings();
} else {
// Create default settings if not exists
$defaultData = [
'enabled_categories' => json_encode(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']),
'items_per_page' => 20,
'default_view' => 'grid',
'show_adult_content' => 0,
'auto_play_trailers' => 0,
'language' => 'en',
'theme' => 'system',
'theme' => 'system'
];
$mergedData = array_merge($defaultData, $updateData);
$this->create($mergedData);
return $this->getSettings();
}
}
}

110
api/services/ApiLogger.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
require_once __DIR__ . '/../database.php';
class ApiLogger {
private static $instance = null;
private $pdo;
private $enabled;
private function __construct() {
$this->enabled = API_LOGGING_ENABLED;
$db = new Database();
$this->pdo = $db->getConnection();
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function logRequest($method, $path, $params = [], $body = null) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, params, body)
VALUES ('REQUEST', :method, :path, :params, :body)
");
$methodValue = is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method;
$pathValue = is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path;
$paramsValue = is_array($params) ? (json_encode($params) ?: '[array]') : (string)$params;
$bodyValue = null;
if ($body) {
$bodyValue = is_array($body) ? (json_encode($body) ?: '[array]') : (string)$body;
}
$stmt->execute([
':method' => $methodValue,
':path' => $pathValue,
':params' => $paramsValue,
':body' => $bodyValue
]);
} catch (Exception $e) {
error_log('Failed to log request: ' . $e->getMessage());
}
}
public function logResponse($method, $path, $statusCode, $response) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, status_code, response)
VALUES ('RESPONSE', :method, :path, :status_code, :response)
");
$stmt->execute([
':method' => is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method,
':path' => is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path,
':status_code' => $statusCode,
':response' => (json_encode($response) ?: '[encoding_failed]')
]);
} catch (Exception $e) {
error_log('Failed to log response: ' . $e->getMessage());
}
}
public function logError($method, $path, $error) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, method, path, error)
VALUES ('ERROR', :method, :path, :error)
");
$stmt->execute([
':method' => is_array($method) ? (json_encode($method) ?: '[array]') : (string)$method,
':path' => is_array($path) ? (json_encode($path) ?: '[array]') : (string)$path,
':error' => is_array($error) ? (json_encode($error) ?: '[array]') : (string)$error
]);
} catch (Exception $e) {
error_log('Failed to log error: ' . $e->getMessage());
}
}
public function logDebug($message) {
if (!$this->enabled) {
return;
}
try {
$stmt = $this->pdo->prepare("
INSERT INTO api_logs (type, message)
VALUES ('DEBUG', :message)
");
$stmt->execute([
':message' => is_array($message) ? (json_encode($message) ?: '[array]') : (string)$message
]);
} catch (Exception $e) {
error_log('Failed to log debug: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,204 @@
<?php
class DocumentationService {
private $controllersPath;
private $modelsPath;
public function __construct($controllersPath = __DIR__ . '/../controllers/', $modelsPath = __DIR__ . '/../models/') {
$this->controllersPath = $controllersPath;
$this->modelsPath = $modelsPath;
}
public function generateDocumentation() {
$docs = [
'title' => 'Media API Documentation',
'version' => '1.0.0',
'baseUrl' => '/api',
'endpoints' => [],
'models' => []
];
// Controller scannen
$docs['endpoints'] = $this->scanControllers();
// Modelle scannen
$docs['models'] = $this->scanModels();
return $docs;
}
private function scanControllers() {
$endpoints = [];
$controllerFiles = glob($this->controllersPath . '*Controller.php');
foreach ($controllerFiles as $file) {
$className = basename($file, '.php');
require_once $file;
$reflection = new ReflectionClass($className);
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
if ($method->getName() === '__construct') {
continue;
}
$docComment = $method->getDocComment();
$endpointInfo = $this->parseMethodDoc($docComment, $method->getName(), $className);
if ($endpointInfo) {
$endpoints[] = $endpointInfo;
}
}
}
return $endpoints;
}
private function parseMethodDoc($docComment, $methodName, $className) {
if (!$docComment) {
return null;
}
$info = [
'controller' => $className,
'method' => $methodName,
'description' => '',
'parameters' => [],
'response' => [],
'example' => null
];
// Beschreibung extrahieren
if (preg_match('/\*\s*(.+?)\n/', $docComment, $matches)) {
$info['description'] = trim($matches[1]);
}
// @param extrahieren
if (preg_match_all('/@param\s+(\w+)\s+\$(\w+)\s+(.+)/', $docComment, $matches)) {
foreach ($matches[1] as $i => $type) {
$info['parameters'][] = [
'name' => $matches[2][$i],
'type' => $type,
'description' => trim($matches[3][$i])
];
}
}
// @return extrahieren
if (preg_match('/@return\s+(\w+)\s+(.+)/', $docComment, $matches)) {
$info['response'] = [
'type' => $matches[1],
'description' => trim($matches[2])
];
}
// @example extrahieren
if (preg_match('/@example\s+(.+)/', $docComment, $matches)) {
$info['example'] = trim($matches[1]);
}
// HTTP-Methoden und Pfade aus Methodennamen ableiten
$info['httpMethods'] = $this->inferHttpMethods($methodName);
$info['path'] = $this->inferPath($className, $methodName);
return $info;
}
private function inferHttpMethods($methodName) {
$methods = [];
if (strpos($methodName, 'get') === 0) {
$methods[] = 'GET';
}
if (strpos($methodName, 'create') !== false || strpos($methodName, 'add') !== false || strpos($methodName, 'post') !== false) {
$methods[] = 'POST';
}
if (strpos($methodName, 'update') !== false) {
$methods[] = 'PUT';
}
if (strpos($methodName, 'delete') !== false) {
$methods[] = 'DELETE';
}
if (empty($methods)) {
$methods[] = 'GET'; // Default
}
return $methods;
}
private function inferPath($className, $methodName) {
$resource = strtolower(str_replace('Controller', '', $className));
$path = "/{$resource}";
if (strpos($methodName, 'getOne') !== false || strpos($methodName, 'update') !== false || strpos($methodName, 'delete') !== false) {
$path .= '/:id';
}
if (strpos($methodName, 'getMedia') !== false) {
$path .= '/:id/media';
}
if (strpos($methodName, 'handleEpisodes') !== false) {
$path .= '/:id/episodes';
}
if (strpos($methodName, 'handleTracks') !== false) {
$path .= '/:id/tracks';
}
if (strpos($methodName, 'handleAdult') !== false) {
$path .= '/adult';
}
return $path;
}
private function scanModels() {
$models = [];
$modelFiles = glob($this->modelsPath . '*.php');
foreach ($modelFiles as $file) {
$className = basename($file, '.php');
if ($className === 'BaseModel' || $className === 'MediaType') {
continue;
}
require_once $file;
$reflection = new ReflectionClass($className);
$docComment = $reflection->getDocComment();
$modelInfo = [
'name' => $className,
'description' => '',
'fields' => []
];
if ($docComment) {
if (preg_match('/\*\s*(.+?)\n/', $docComment, $matches)) {
$modelInfo['description'] = trim($matches[1]);
}
}
// Properties aus Klasse ableiten
$properties = $reflection->getProperties();
foreach ($properties as $property) {
if ($property->isPublic() || $property->isProtected()) {
$modelInfo['fields'][] = [
'name' => $property->getName(),
'type' => 'mixed',
'description' => ''
];
}
}
$models[] = $modelInfo;
}
return $models;
}
}

View File

@@ -0,0 +1,183 @@
<?php
class ImageHandler {
private $uploadDir;
private $baseUrl;
public function __construct($uploadDir = null, $baseUrl = null) {
$this->uploadDir = $uploadDir ?? __DIR__ . '/../public/images/';
$this->baseUrl = $baseUrl ?? '/images/';
// Ensure upload directory exists
if (!file_exists($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
/**
* Process base64 image data and save to file
*
* @param string $base64Data Base64 encoded image data
* @param string $prefix Prefix for filename (e.g., 'poster', 'banner')
* @return string|null Relative path to saved image, or null if invalid
*/
public function saveBase64Image($base64Data, $prefix = 'image') {
error_log("ImageHandler: Starting to process base64 image, length: " . strlen($base64Data));
if (empty($base64Data)) {
error_log("ImageHandler: Empty base64 data");
return null;
}
// Check if it's already a URL (not base64)
if (filter_var($base64Data, FILTER_VALIDATE_URL)) {
error_log("ImageHandler: Data is already a URL: " . $base64Data);
return $base64Data;
}
// Check if it's already a file path
if (strpos($base64Data, '/images/') === 0) {
error_log("ImageHandler: Data is already a file path: " . $base64Data);
return $base64Data;
}
// Parse base64 data
if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $base64Data, $matches)) {
$extension = $matches[1];
$base64String = $matches[2];
error_log("ImageHandler: Data URI format detected, extension: " . $extension);
} elseif (preg_match('/^\/9j\//', $base64Data)) {
// Raw base64 without data URI prefix (JPEG)
$extension = 'jpg';
$base64String = $base64Data;
error_log("ImageHandler: Raw JPEG base64 detected");
} else {
// Try to detect from the base64 string itself
$extension = $this->detectImageFormat($base64Data);
if (!$extension) {
error_log("ImageHandler: Could not detect image format, defaulting to jpg");
$extension = 'jpg';
$base64String = $base64Data;
} else {
$base64String = $base64Data;
error_log("ImageHandler: Detected format: " . $extension);
}
}
// Decode base64
$imageData = base64_decode($base64String);
if ($imageData === false) {
error_log("ImageHandler: Base64 decode failed");
return null;
}
error_log("ImageHandler: Decoded image data size: " . strlen($imageData) . " bytes");
// Skip GD validation - just check if data looks reasonable
if (strlen($imageData) < 100) {
error_log("ImageHandler: Image data too small, likely invalid");
return null;
}
// Generate unique filename
$filename = $this->generateUniqueFilename($prefix, $extension);
$filepath = $this->uploadDir . $filename;
error_log("ImageHandler: Attempting to save to: " . $filepath);
// Ensure directory exists and is writable (handle subdirectories)
$directory = dirname($filepath);
if (!file_exists($directory)) {
error_log("ImageHandler: Creating directory: " . $directory);
if (!mkdir($directory, 0755, true)) {
error_log("ImageHandler: Failed to create directory");
return null;
}
}
if (!is_writable($directory)) {
error_log("ImageHandler: Directory not writable: " . $directory);
error_log("ImageHandler: Attempting to chmod directory");
chmod($directory, 0755);
}
// Save file
$bytesWritten = file_put_contents($filepath, $imageData);
if ($bytesWritten === false) {
error_log("ImageHandler: Failed to save file to: " . $filepath);
error_log("ImageHandler: Upload directory exists: " . (file_exists($this->uploadDir) ? 'yes' : 'no'));
error_log("ImageHandler: Upload directory writable: " . (is_writable($this->uploadDir) ? 'yes' : 'no'));
return null;
}
error_log("ImageHandler: Successfully saved " . $bytesWritten . " bytes to: " . $filepath);
// Return relative path
return $this->baseUrl . $filename;
}
/**
* Detect image format from base64 string
*/
private function detectImageFormat($base64String) {
// Decode first few bytes to check magic numbers
$data = base64_decode(substr($base64String, 0, 100));
if (substr($data, 0, 3) === "\xFF\xD8\xFF") {
return 'jpg';
} elseif (substr($data, 0, 8) === "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") {
return 'png';
} elseif (substr($data, 0, 6) === "GIF87a" || substr($data, 0, 6) === "GIF89a") {
return 'gif';
} elseif (substr($data, 0, 4) === "RIFF" && substr($data, 8, 4) === "WEBP") {
return 'webp';
}
// Default to jpg for game posters
return 'jpg';
}
/**
* Validate that data is a valid image
*/
private function isValidImage($data) {
try {
$image = imagecreatefromstring($data);
if ($image !== false) {
imagedestroy($image);
return true;
}
} catch (Exception $e) {
return false;
}
return false;
}
/**
* Generate unique filename
*/
private function generateUniqueFilename($prefix, $extension) {
return $prefix . '_' . uniqid() . '_' . time() . '.' . $extension;
}
/**
* Delete an image file
*/
public function deleteImage($imagePath) {
if (empty($imagePath)) {
return false;
}
// Convert URL to filesystem path
if (strpos($imagePath, $this->baseUrl) === 0) {
$filename = substr($imagePath, strlen($this->baseUrl));
$filepath = $this->uploadDir . $filename;
if (file_exists($filepath)) {
return unlink($filepath);
}
}
return false;
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "Jane Smith",
"photo": "https://example.com/jane-smith.jpg",
"bio": "Adult film actress and model",
"birthDate": "1998-03-20",
"birthPlace": "Miami, Florida",
"occupations": ["Actress", "Model"],
"adult_specifics": {
"bust_size": "36",
"cup_size": "DD",
"waist_size": "26",
"hip_size": "38",
"height": "170",
"weight": "58",
"hair_color": "Brunette",
"eye_color": "Green",
"ethnicity": "Latina",
"tattoos": "Lower back",
"piercings": "None",
"measurements": "36-26-38",
"shoe_size": "8"
}
}

View File

@@ -0,0 +1,28 @@
{
"title": "Thriller",
"year": 1982,
"poster": "https://example.com/thriller-cover.jpg",
"description": "Sixth studio album by Michael Jackson",
"rating": 9.0,
"category": "Music",
"type": "Album",
"status": "Released",
"genres": ["Pop", "Funk", "Rock"],
"tags": ["Classic", "Best-selling"],
"studios": ["Epic Records"],
"staff": [],
"tracks": [
{
"track_number": 1,
"title": "Wanna Be Startin' Somethin'",
"duration": "6:03",
"artist": "Michael Jackson"
},
{
"track_number": 2,
"title": "Baby Be Mine",
"duration": "4:20",
"artist": "Michael Jackson"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "Tom Hardy",
"photo": "https://example.com/tom.jpg",
"bio": "English actor known for versatile roles",
"birthDate": "1977-09-15",
"birthPlace": "Hammersmith, London, England",
"occupations": ["Actor", "Producer", "Writer"]
}

View File

@@ -0,0 +1,9 @@
{
"season": 1,
"episode_number": 3,
"title": "...And the Bag's in the River",
"description": "Walter and Jesse deal with the aftermath.",
"air_date": "2008-02-03",
"duration": 47,
"thumbnail": "https://example.com/ep3.jpg"
}

View File

@@ -0,0 +1,69 @@
{
"type": "Game",
"title": "1-2-Switch",
"sortingName": "1-02-Switch",
"description": "1-2-Switch is a party game for everyone! Throw an impromptu party anywhere with anyone thanks to a new play style in which players look at each other—not the screen!",
"notes": null,
"genres": ["Arcade"],
"categories": ["1-2-Switch"],
"tags": [],
"features": ["Multiplayer"],
"platforms": ["Nintendo Switch"],
"developers": ["Nintendo", "Nintendo Entertainment Planning & Development", "Nintendo EPD Production Group No. 4"],
"publishers": ["Nintendo"],
"series": ["1-2-Switch"],
"ageRatings": ["PEGI 7"],
"regions": [],
"source": "RomM",
"gameId": "!0ChIJR6bm+qTMQUsRjxmwJLs2yzQSUmh0dHA6Ly8xOTIuMTY4LjEuMTAyOjY2NTUvYXBpL3JvbXMvNDEzL2NvbnRlbnQvMS0yLVN3aXRjaFswMTAwMDMyMDAwMENDMDAwXVswXS5uc3AaIzEtMi1Td2l0Y2hbMDEwMDAzMjAwMDBDQzAwMF1bMF0ubnNw",
"pluginId": "9700aa21-447d-41b4-a989-acd38f407d9f",
"completionStatus": "Not Played",
"releaseDate": "2017-03-03",
"isInstalled": false,
"installDirectory": "E:\\Programme\\Emulators\\Games\\1-2-Switch[01000320000CC000][0]",
"installSize": 1481371442,
"hidden": false,
"favorite": false,
"playtime": 0,
"playCount": 0,
"lastActivity": null,
"added": "2026-04-09T17:05:10.9260000+02:00",
"modified": "2026-04-09T17:10:23.1760000+02:00",
"communityScore": 51,
"criticScore": 54,
"userScore": null,
"hasIcon": true,
"hasCover": true,
"hasBackground": true,
"version": null,
"links": [
{
"name": "Official Website",
"url": "http://1-2-switch.nintendo.com/"
},
{
"name": "Wikipedia",
"url": "https://en.wikipedia.org/wiki/1-2-Switch"
},
{
"name": "Community Wiki",
"url": "http://nintendo.wikia.com/wiki/1-2-Switch"
}
],
"achievements": [
{
"name": "First Victory",
"description": "Win your first game",
"icon": "https://example.com/achievement-icon.png",
"unlocked": true,
"unlocked_date": "2026-04-09T18:00:00"
},
{
"name": "Master Player",
"description": "Win 100 games",
"icon": "https://example.com/master-icon.png",
"unlocked": false,
"unlocked_date": null
}
]
}

View File

@@ -0,0 +1,32 @@
{
"title": "The Matrix",
"year": 1999,
"poster": "https://example.com/matrix-poster.jpg",
"banner": "https://example.com/matrix-banner.jpg",
"description": "A computer hacker learns about the true nature of reality.",
"rating": 8.7,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 136,
"director": "The Wachowskis",
"writer": "The Wachowskis",
"releaseDate": "1999-03-31",
"genres": ["Sci-Fi", "Action"],
"tags": ["Cyberpunk", "AI", "Simulation"],
"studios": ["Warner Bros."],
"staff": [
{
"name": "Keanu Reeves",
"photo": "https://example.com/keanu.jpg",
"bio": "Canadian actor",
"birthDate": "1964-09-02",
"birthPlace": "Beirut, Lebanon",
"role": "Actor",
"characterName": "Neo",
"characterImage": null,
"occupations": ["Actor"]
}
]
}

View File

@@ -0,0 +1,6 @@
{
"track_number": 3,
"title": "On the Run",
"duration": "3:35",
"artist": "Pink Floyd"
}

View File

@@ -0,0 +1,29 @@
{
"title": "Stranger Things",
"year": 2016,
"poster": "https://example.com/st-poster.jpg",
"description": "When a young boy disappears, his mother uncovers a mystery.",
"rating": 8.7,
"category": "TV",
"type": "TV",
"status": "Ongoing",
"runtime": 50,
"director": "The Duffer Brothers",
"writer": "The Duffer Brothers",
"releaseDate": "2016-07-15",
"genres": ["Sci-Fi", "Horror", "Drama"],
"tags": ["80s", "Supernatural", "Government Conspiracy"],
"studios": ["Netflix"],
"staff": [],
"episodes": [
{
"season": 1,
"episode_number": 1,
"title": "Chapter One: The Vanishing of Will Byers",
"description": "On his way home from a friend's house, young Will sees something terrifying.",
"air_date": "2016-07-15",
"duration": 47,
"thumbnail": "https://example.com/st-ep1.jpg"
}
]
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian"
}
],
"total": 25,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,32 @@
{
"success": true,
"data": {
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"filmography": [],
"adult_specifics": {
"id": 5,
"cast_id": 10,
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian",
"tattoos": "None",
"piercings": "Ears",
"measurements": "34-24-34",
"shoe_size": "7"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 5,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,27 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,36 @@
{
"success": true,
"data": {
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actor", "Producer"],
"filmography": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,29 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 2,
"season": 1,
"episode_number": 1,
"title": "Pilot",
"description": "Walter White is diagnosed with lung cancer.",
"air_date": "2008-01-20",
"duration": 49,
"thumbnail": "https://example.com/ep1.jpg"
},
{
"id": 2,
"media_id": 2,
"season": 1,
"episode_number": 2,
"title": "Cat's in the Bag...",
"description": "Walter and Jesse attempt to dispose of the body.",
"air_date": "2008-01-27",
"duration": 48,
"thumbnail": "https://example.com/ep2.jpg"
}
]
}
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 150,
"page": 1,
"limit": 10,
"totalPages": 15
}
}

View File

@@ -0,0 +1,39 @@
{
"success": true,
"data": {
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"genres": ["Sci-Fi", "Action", "Thriller"],
"tags": ["Mind-bending", "Dream", "Heist"],
"studios": ["Warner Bros.", "Legendary Pictures"],
"staff": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"role": "Actor",
"characterName": "Dom Cobb",
"characterImage": null,
"occupations": ["Actor", "Producer"]
}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 3,
"track_number": 1,
"title": "Speak to Me",
"duration": "1:30",
"artist": "Pink Floyd"
},
{
"id": 2,
"media_id": 3,
"track_number": 2,
"title": "Breathe",
"duration": "2:43",
"artist": "Pink Floyd"
}
]
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "Jane Smith (Updated)",
"bio": "Updated bio",
"adult_specifics": {
"hair_color": "Red",
"weight": "56"
}
}

View File

@@ -0,0 +1,4 @@
{
"name": "Tom Hardy (Updated)",
"bio": "Updated bio description"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Episode Title",
"description": "Updated description"
}

View File

@@ -0,0 +1,32 @@
{
"type": "Game",
"title": "1-2-Switch",
"playtime": 120,
"completionStatus": "Completed",
"favorite": true,
"communityScore": 55,
"userScore": 80,
"achievements": [
{
"name": "First Victory",
"description": "Win your first game",
"icon": "https://example.com/achievement-icon.png",
"unlocked": true,
"unlocked_date": "2026-04-09T18:00:00"
},
{
"name": "Master Player",
"description": "Win 100 games",
"icon": "https://example.com/master-icon.png",
"unlocked": true,
"unlocked_date": "2026-04-09T20:30:00"
},
{
"name": "Champion",
"description": "Win 1000 games",
"icon": "https://example.com/champion-icon.png",
"unlocked": false,
"unlocked_date": null
}
]
}

View File

@@ -0,0 +1,5 @@
{
"title": "The Matrix (Updated)",
"rating": 8.8,
"status": "Released"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Track Title",
"duration": "4:00"
}

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
version: '3.8'
services:
php:
build:
context: .
dockerfile: Dockerfile
container_name: kyoo-php
ports:
- "6400:80"
volumes:
- ./api:/var/www/html
depends_on:
- mariadb
networks:
- kyoo-network
environment:
- DB_HOST=mariadb
- DB_NAME=kyoo
- DB_USER=kyoo_user
- DB_PASS=kyoo_password
mariadb:
image: mariadb:10.11
container_name: kyoo-mariadb
ports:
- "6401:3306"
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=kyoo
- MYSQL_USER=kyoo_user
- MYSQL_PASSWORD=kyoo_password
volumes:
- mariadb-data:/var/lib/mysql
networks:
- kyoo-network
phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
container_name: kyoo-phpmyadmin
ports:
- "6402:80"
environment:
- PMA_HOST=mariadb
- PMA_PORT=3306
- PMA_USER=kyoo_user
- PMA_PASSWORD=kyoo_password
depends_on:
- mariadb
networks:
- kyoo-network
networks:
kyoo-network:
driver: bridge
volumes:
mariadb-data:

4
php-custom.ini Normal file
View File

@@ -0,0 +1,4 @@
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 256M
max_execution_time = 300