From 66f69bc90ddffad30c6738be6643792d526eb3b1 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Sun, 12 Apr 2026 00:46:30 +0200 Subject: [PATCH] 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. --- .gitignore | 56 ++ API_EXAMPLES.md | 1035 +++++++++++++++++++++++ DOCKER_README.md | 87 ++ Dockerfile | 13 + api/.htaccess | 4 + api/README.md | 472 +++++++++++ api/Router.php | 116 +++ api/config.php | 39 + api/controllers/CastController.php | 256 ++++++ api/controllers/ImageController.php | 68 ++ api/controllers/MediaController.php | 403 +++++++++ api/controllers/SettingsController.php | 61 ++ api/database.php | 388 +++++++++ api/index.php | 37 + api/models/Adult.php | 85 ++ api/models/AdultCast.php | 295 +++++++ api/models/BaseModel.php | 100 +++ api/models/Cast.php | 190 +++++ api/models/Console.php | 23 + api/models/Game.php | 469 ++++++++++ api/models/Media.php | 291 +++++++ api/models/MediaType.php | 39 + api/models/Movie.php | 88 ++ api/models/Music.php | 112 +++ api/models/Series.php | 140 +++ api/models/Settings.php | 82 ++ api/services/ApiLogger.php | 110 +++ api/services/DocumentationService.php | 204 +++++ api/services/ImageHandler.php | 183 ++++ api_examples/create_adult_cast.json | 23 + api_examples/create_album.json | 28 + api_examples/create_cast.json | 8 + api_examples/create_episode.json | 9 + api_examples/create_game.json | 69 ++ api_examples/create_movie.json | 32 + api_examples/create_track.json | 6 + api_examples/create_tv.json | 29 + api_examples/get_adult_cast.json | 30 + api_examples/get_adult_cast_single.json | 32 + api_examples/get_cast.json | 20 + api_examples/get_cast_media.json | 27 + api_examples/get_cast_single.json | 36 + api_examples/get_episodes.json | 29 + api_examples/get_media.json | 30 + api_examples/get_media_single.json | 39 + api_examples/get_tracks.json | 23 + api_examples/update_adult_cast.json | 8 + api_examples/update_cast.json | 4 + api_examples/update_episode.json | 4 + api_examples/update_game.json | 32 + api_examples/update_media.json | 5 + api_examples/update_track.json | 4 + docker-compose.yml | 58 ++ php-custom.ini | 4 + 54 files changed, 6035 insertions(+) create mode 100644 .gitignore create mode 100644 API_EXAMPLES.md create mode 100644 DOCKER_README.md create mode 100644 Dockerfile create mode 100644 api/.htaccess create mode 100644 api/README.md create mode 100644 api/Router.php create mode 100644 api/config.php create mode 100644 api/controllers/CastController.php create mode 100644 api/controllers/ImageController.php create mode 100644 api/controllers/MediaController.php create mode 100644 api/controllers/SettingsController.php create mode 100644 api/database.php create mode 100644 api/index.php create mode 100644 api/models/Adult.php create mode 100644 api/models/AdultCast.php create mode 100644 api/models/BaseModel.php create mode 100644 api/models/Cast.php create mode 100644 api/models/Console.php create mode 100644 api/models/Game.php create mode 100644 api/models/Media.php create mode 100644 api/models/MediaType.php create mode 100644 api/models/Movie.php create mode 100644 api/models/Music.php create mode 100644 api/models/Series.php create mode 100644 api/models/Settings.php create mode 100644 api/services/ApiLogger.php create mode 100644 api/services/DocumentationService.php create mode 100644 api/services/ImageHandler.php create mode 100644 api_examples/create_adult_cast.json create mode 100644 api_examples/create_album.json create mode 100644 api_examples/create_cast.json create mode 100644 api_examples/create_episode.json create mode 100644 api_examples/create_game.json create mode 100644 api_examples/create_movie.json create mode 100644 api_examples/create_track.json create mode 100644 api_examples/create_tv.json create mode 100644 api_examples/get_adult_cast.json create mode 100644 api_examples/get_adult_cast_single.json create mode 100644 api_examples/get_cast.json create mode 100644 api_examples/get_cast_media.json create mode 100644 api_examples/get_cast_single.json create mode 100644 api_examples/get_episodes.json create mode 100644 api_examples/get_media.json create mode 100644 api_examples/get_media_single.json create mode 100644 api_examples/get_tracks.json create mode 100644 api_examples/update_adult_cast.json create mode 100644 api_examples/update_cast.json create mode 100644 api_examples/update_episode.json create mode 100644 api_examples/update_game.json create mode 100644 api_examples/update_media.json create mode 100644 api_examples/update_track.json create mode 100644 docker-compose.yml create mode 100644 php-custom.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dac37e5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/API_EXAMPLES.md b/API_EXAMPLES.md new file mode 100644 index 0000000..30b17ff --- /dev/null +++ b/API_EXAMPLES.md @@ -0,0 +1,1035 @@ +# API Beispiele - Media API v1.0 + +Diese Dokumentation enthält Beispiel-JSONs für alle API-Endpunkte, um zu zeigen wie Daten ausgelesen werden und was beim Anlegen übergeben werden muss. + +Base URL: `/api` + +--- + +## MEDIA ENDPOINTS + +### GET /api/media +Alle Medien abrufen mit Filterung und Pagination. + +**Query Parameter:** +- `category` - Filter nach Kategorie (Movie, TV, Album, etc.) +- `type` - Filter nach Typ +- `search` - Suche in Titel und Beschreibung +- `page` - Seitennummer (Standard: 1) +- `limit` - Ergebnisse pro Seite (Standard: 20) + +**Beispiel Request:** +``` +GET /api/media?category=Movie&page=1&limit=10 +``` + +**Beispiel Response:** +```json +{ + "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 + } +} +``` + +--- + +### GET /api/media/:id +Ein einzelnes Medium abrufen mit allen Relationen (Genres, Tags, Studios, Cast). + +**Beispiel Request:** +``` +GET /api/media/1 +``` + +**Beispiel Response (Movie):** +```json +{ + "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"] + } + ] + } +} +``` + +**Beispiel Response (TV Series mit Episoden):** +```json +{ + "success": true, + "data": { + "id": 2, + "title": "Breaking Bad", + "year": 2008, + "poster": "https://example.com/bb-poster.jpg", + "description": "A high school chemistry teacher turned methamphetamine manufacturer.", + "rating": 9.5, + "category": "TV", + "type": "TV", + "status": "Ended", + "runtime": 49, + "director": "Vince Gilligan", + "writer": "Vince Gilligan", + "releaseDate": "2008-01-20", + "genres": ["Crime", "Drama", "Thriller"], + "tags": ["Anti-hero", "Drug Trade", "New Mexico"], + "studios": ["Sony Pictures Television"], + "staff": [], + "episodes": [ + { + "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" + } + ], + "seasons": [ + { + "season": 1, + "episode_count": 7, + "first_air_date": "2008-01-20", + "last_air_date": "2008-03-09" + } + ] + } +} +``` + +**Beispiel Response (Album mit Tracks):** +```json +{ + "success": true, + "data": { + "id": 3, + "title": "Dark Side of the Moon", + "year": 1973, + "poster": "https://example.com/album-cover.jpg", + "description": "Eighth studio album by Pink Floyd", + "rating": 9.2, + "category": "Music", + "type": "Album", + "status": "Released", + "genres": ["Progressive Rock", "Psychedelic Rock"], + "tags": ["Classic", "Concept Album"], + "studios": ["Harvest Records"], + "staff": [], + "tracks": [ + { + "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" + } + ] + } +} +``` + +--- + +### POST /api/media +Neues Medium erstellen. + +**Request Body (Movie):** +```json +{ + "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"] + } + ] +} +``` + +**Request Body (TV Series mit Episoden):** +```json +{ + "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" + } + ] +} +``` + +**Request Body (Album mit Tracks):** +```json +{ + "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" + } + ] +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 4 + } +} +``` + +--- + +### PUT /api/media/:id +Medium aktualisieren. Nur die zu ändernden Felder übergeben. + +**Request Body:** +```json +{ + "title": "The Matrix (Updated)", + "rating": 8.8, + "status": "Released" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 1 + } +} +``` + +--- + +### DELETE /api/media/:id +Medium löschen. + +**Beispiel Request:** +``` +DELETE /api/media/1 +``` + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Media deleted successfully" +} +``` + +--- + +### GET /api/media/:id/episodes +Alle Episoden einer Serie abrufen. + +**Query Parameter:** +- `season` - Nur Episoden einer bestimmten Staffel + +**Beispiel Request:** +``` +GET /api/media/2/episodes?season=1 +``` + +**Beispiel Response:** +```json +{ + "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" + } + ] + } +} +``` + +--- + +### POST /api/media/:id/episodes +Neue Episode zu einer Serie hinzufügen. + +**Request Body:** +```json +{ + "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" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 3 + } +} +``` + +--- + +### PUT /api/media/:id/episodes/:episodeId +Episode aktualisieren. + +**Request Body:** +```json +{ + "title": "Updated Episode Title", + "description": "Updated description" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 1 + } +} +``` + +--- + +### DELETE /api/media/:id/episodes/:episodeId +Episode löschen. + +**Beispiel Request:** +``` +DELETE /api/media/2/episodes/1 +``` + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Episode deleted successfully" +} +``` + +--- + +### GET /api/media/:id/tracks +Alle Tracks eines Albums abrufen. + +**Beispiel Request:** +``` +GET /api/media/3/tracks +``` + +**Beispiel Response:** +```json +{ + "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" + } + ] + } +} +``` + +--- + +### POST /api/media/:id/tracks +Neuen Track zu einem Album hinzufügen. + +**Request Body:** +```json +{ + "track_number": 3, + "title": "On the Run", + "duration": "3:35", + "artist": "Pink Floyd" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 3 + } +} +``` + +--- + +### PUT /api/media/:id/tracks/:trackId +Track aktualisieren. + +**Request Body:** +```json +{ + "title": "Updated Track Title", + "duration": "4:00" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 1 + } +} +``` + +--- + +### DELETE /api/media/:id/tracks/:trackId +Track löschen. + +**Beispiel Request:** +``` +DELETE /api/media/3/tracks/1 +``` + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Track deleted successfully" +} +``` + +--- + +## CAST ENDPOINTS + +### GET /api/cast +Alle Cast-Mitglieder abrufen mit Filterung und Pagination. + +**Query Parameter:** +- `search` - Suche nach Name +- `page` - Seitennummer (Standard: 1) +- `limit` - Ergebnisse pro Seite (Standard: 20) + +**Beispiel Request:** +``` +GET /api/cast?search=Leonardo&page=1&limit=10 +``` + +**Beispiel Response:** +```json +{ + "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 + } +} +``` + +--- + +### GET /api/cast/:id +Ein einzelnes Cast-Mitglied abrufen mit Filmografie. + +**Beispiel Request:** +``` +GET /api/cast/1 +``` + +**Beispiel Response:** +```json +{ + "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" + } + ] + } +} +``` + +--- + +### GET /api/cast/:id/media +Alle Medien eines Cast-Mitglieds abrufen. + +**Beispiel Request:** +``` +GET /api/cast/1/media +``` + +**Beispiel Response:** +```json +{ + "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" + } + ] + } +} +``` + +--- + +### POST /api/cast +Neues Cast-Mitglied erstellen. + +**Request Body:** +```json +{ + "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"] +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 5 + } +} +``` + +--- + +### PUT /api/cast/:id +Cast-Mitglied aktualisieren. Nur die zu ändernden Felder übergeben. + +**Request Body:** +```json +{ + "name": "Tom Hardy (Updated)", + "bio": "Updated bio description" +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 5 + } +} +``` + +--- + +### DELETE /api/cast/:id +Cast-Mitglied löschen. + +**Beispiel Request:** +``` +DELETE /api/cast/1 +``` + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Cast member deleted successfully" +} +``` + +--- + +## ADULT CAST ENDPOINTS + +### GET /api/cast/adult +Alle Adult-Cast-Mitglieder abrufen mit erweiterten Filtern. + +**Query Parameter:** +- `search` - Suche nach Name +- `ethnicity` - Filter nach Ethnie +- `hair_color` - Filter nach Haarfarbe +- `page` - Seitennummer (Standard: 1) +- `limit` - Ergebnisse pro Seite (Standard: 20) + +**Beispiel Request:** +``` +GET /api/cast/adult?ethnicity=Caucasian&hair_color=Blonde&page=1&limit=10 +``` + +**Beispiel Response:** +```json +{ + "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 + } +} +``` + +--- + +### GET /api/cast/adult/:id +Ein einzelnes Adult-Cast-Mitglied abrufen mit allen Spezifikationen. + +**Beispiel Request:** +``` +GET /api/cast/adult/10 +``` + +**Beispiel Response:** +```json +{ + "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" + } + } +} +``` + +--- + +### POST /api/cast/adult +Neues Adult-Cast-Mitglied erstellen mit Spezifikationen. + +**Request Body:** +```json +{ + "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" + } +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 11 + } +} +``` + +--- + +### PUT /api/cast/adult/:id +Adult-Cast-Mitglied aktualisieren. Nur die zu ändernden Felder übergeben. + +**Request Body:** +```json +{ + "name": "Jane Smith (Updated)", + "bio": "Updated bio", + "adult_specifics": { + "hair_color": "Red", + "weight": "56" + } +} +``` + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "id": 11 + } +} +``` + +--- + +### DELETE /api/cast/adult/:id +Adult-spezifische Daten löschen (nicht das Cast-Mitglied selbst). + +**Beispiel Request:** +``` +DELETE /api/cast/adult/10 +``` + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Adult specifics deleted successfully" +} +``` + +--- + +## ALLGEMEINE ENDPOINTS + +### GET /api +API-Informationen und verfügbare Endpunkte. + +**Beispiel Response:** +```json +{ + "success": true, + "message": "Media API v1.0", + "endpoints": { + "GET /api/docs": "Automatische API-Dokumentation", + "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/docs +Automatische API-Dokumentation. + +**Beispiel Response:** +```json +{ + "success": true, + "data": { + "title": "Media API Documentation", + "version": "1.0.0", + "baseUrl": "/api", + "endpoints": [...], + "models": [...] + } +} +``` + +--- + +## HINWEISE + +1. **Content-Type**: Alle POST und PUT Requests müssen `Content-Type: application/json` Header haben. + +2. **Pagination**: Bei Listen-Endpoints (GET ohne ID) werden standardmäßig 20 Ergebnisse pro Seite zurückgegeben. + +3. **Filter**: Viele GET-Endpoints unterstützen Filter über Query-Parameter. + +4. **Partial Updates**: Bei PUT-Endpoints müssen nur die zu ändernden Felder übergeben werden. + +5. **Relationen**: Beim Erstellen können Relationen (Genres, Tags, Studios, Staff) direkt mitgegeben werden. + +6. **Typ-spezifische Felder**: + - TV Series haben zusätzlich `episodes` Array + - Alben haben zusätzlich `tracks` Array + - Adult Cast haben zusätzlich `adult_specifics` Object + +7. **Fehler-Response**: +```json +{ + "success": false, + "error": "Fehlerbeschreibung" +} +``` diff --git a/DOCKER_README.md b/DOCKER_README.md new file mode 100644 index 0000000..02a04a3 --- /dev/null +++ b/DOCKER_README.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..319c717 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/api/.htaccess b/api/.htaccess new file mode 100644 index 0000000..a936070 --- /dev/null +++ b/api/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..6aa52f7 --- /dev/null +++ b/api/README.md @@ -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 +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' + ] + ]; + } +} diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..326ab68 --- /dev/null +++ b/api/config.php @@ -0,0 +1,39 @@ +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']; + } +} diff --git a/api/controllers/ImageController.php b/api/controllers/ImageController.php new file mode 100644 index 0000000..5192029 --- /dev/null +++ b/api/controllers/ImageController.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/api/controllers/MediaController.php b/api/controllers/MediaController.php new file mode 100644 index 0000000..3e04443 --- /dev/null +++ b/api/controllers/MediaController.php @@ -0,0 +1,403 @@ +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']; + } +} diff --git a/api/controllers/SettingsController.php b/api/controllers/SettingsController.php new file mode 100644 index 0000000..cb62f78 --- /dev/null +++ b/api/controllers/SettingsController.php @@ -0,0 +1,61 @@ +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]; + } +} diff --git a/api/database.php b/api/database.php new file mode 100644 index 0000000..4c9e1fc --- /dev/null +++ b/api/database.php @@ -0,0 +1,388 @@ +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; + } +} diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..5c5eff7 --- /dev/null +++ b/api/index.php @@ -0,0 +1,37 @@ +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()]); +} diff --git a/api/models/Adult.php b/api/models/Adult.php new file mode 100644 index 0000000..7c46a73 --- /dev/null +++ b/api/models/Adult.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/api/models/AdultCast.php b/api/models/AdultCast.php new file mode 100644 index 0000000..9321735 --- /dev/null +++ b/api/models/AdultCast.php @@ -0,0 +1,295 @@ +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 + ]; + } +} diff --git a/api/models/BaseModel.php b/api/models/BaseModel.php new file mode 100644 index 0000000..c13b939 --- /dev/null +++ b/api/models/BaseModel.php @@ -0,0 +1,100 @@ +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; + } +} diff --git a/api/models/Cast.php b/api/models/Cast.php new file mode 100644 index 0000000..4291049 --- /dev/null +++ b/api/models/Cast.php @@ -0,0 +1,190 @@ +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]); + } + } +} diff --git a/api/models/Console.php b/api/models/Console.php new file mode 100644 index 0000000..e2d37e4 --- /dev/null +++ b/api/models/Console.php @@ -0,0 +1,23 @@ +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); + } +} diff --git a/api/models/Media.php b/api/models/Media.php new file mode 100644 index 0000000..8fcc1fd --- /dev/null +++ b/api/models/Media.php @@ -0,0 +1,291 @@ +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 + ]); + } + } + } +} diff --git a/api/models/MediaType.php b/api/models/MediaType.php new file mode 100644 index 0000000..e34dc78 --- /dev/null +++ b/api/models/MediaType.php @@ -0,0 +1,39 @@ +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 []; + } +} diff --git a/api/models/Movie.php b/api/models/Movie.php new file mode 100644 index 0000000..06d06bd --- /dev/null +++ b/api/models/Movie.php @@ -0,0 +1,88 @@ +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); + } +} diff --git a/api/models/Music.php b/api/models/Music.php new file mode 100644 index 0000000..1ca1651 --- /dev/null +++ b/api/models/Music.php @@ -0,0 +1,112 @@ +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; + } +} diff --git a/api/models/Series.php b/api/models/Series.php new file mode 100644 index 0000000..8936725 --- /dev/null +++ b/api/models/Series.php @@ -0,0 +1,140 @@ +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; + } +} diff --git a/api/models/Settings.php b/api/models/Settings.php new file mode 100644 index 0000000..c031df0 --- /dev/null +++ b/api/models/Settings.php @@ -0,0 +1,82 @@ +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(); + } + } +} diff --git a/api/services/ApiLogger.php b/api/services/ApiLogger.php new file mode 100644 index 0000000..0a8320d --- /dev/null +++ b/api/services/ApiLogger.php @@ -0,0 +1,110 @@ +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()); + } + } +} diff --git a/api/services/DocumentationService.php b/api/services/DocumentationService.php new file mode 100644 index 0000000..17a0ade --- /dev/null +++ b/api/services/DocumentationService.php @@ -0,0 +1,204 @@ +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; + } +} diff --git a/api/services/ImageHandler.php b/api/services/ImageHandler.php new file mode 100644 index 0000000..ffef8da --- /dev/null +++ b/api/services/ImageHandler.php @@ -0,0 +1,183 @@ +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; + } +} diff --git a/api_examples/create_adult_cast.json b/api_examples/create_adult_cast.json new file mode 100644 index 0000000..b0432d1 --- /dev/null +++ b/api_examples/create_adult_cast.json @@ -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" + } +} diff --git a/api_examples/create_album.json b/api_examples/create_album.json new file mode 100644 index 0000000..f8c4f60 --- /dev/null +++ b/api_examples/create_album.json @@ -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" + } + ] +} diff --git a/api_examples/create_cast.json b/api_examples/create_cast.json new file mode 100644 index 0000000..64f3841 --- /dev/null +++ b/api_examples/create_cast.json @@ -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"] +} diff --git a/api_examples/create_episode.json b/api_examples/create_episode.json new file mode 100644 index 0000000..fbe1c52 --- /dev/null +++ b/api_examples/create_episode.json @@ -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" +} diff --git a/api_examples/create_game.json b/api_examples/create_game.json new file mode 100644 index 0000000..2b2244b --- /dev/null +++ b/api_examples/create_game.json @@ -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 + } + ] +} diff --git a/api_examples/create_movie.json b/api_examples/create_movie.json new file mode 100644 index 0000000..67c4ef5 --- /dev/null +++ b/api_examples/create_movie.json @@ -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"] + } + ] +} diff --git a/api_examples/create_track.json b/api_examples/create_track.json new file mode 100644 index 0000000..6ad8517 --- /dev/null +++ b/api_examples/create_track.json @@ -0,0 +1,6 @@ +{ + "track_number": 3, + "title": "On the Run", + "duration": "3:35", + "artist": "Pink Floyd" +} diff --git a/api_examples/create_tv.json b/api_examples/create_tv.json new file mode 100644 index 0000000..15cd5a0 --- /dev/null +++ b/api_examples/create_tv.json @@ -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" + } + ] +} diff --git a/api_examples/get_adult_cast.json b/api_examples/get_adult_cast.json new file mode 100644 index 0000000..d73181a --- /dev/null +++ b/api_examples/get_adult_cast.json @@ -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 + } +} diff --git a/api_examples/get_adult_cast_single.json b/api_examples/get_adult_cast_single.json new file mode 100644 index 0000000..708b8e8 --- /dev/null +++ b/api_examples/get_adult_cast_single.json @@ -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" + } + } +} diff --git a/api_examples/get_cast.json b/api_examples/get_cast.json new file mode 100644 index 0000000..dca2490 --- /dev/null +++ b/api_examples/get_cast.json @@ -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 + } +} diff --git a/api_examples/get_cast_media.json b/api_examples/get_cast_media.json new file mode 100644 index 0000000..9ea4236 --- /dev/null +++ b/api_examples/get_cast_media.json @@ -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" + } + ] + } +} diff --git a/api_examples/get_cast_single.json b/api_examples/get_cast_single.json new file mode 100644 index 0000000..b655b79 --- /dev/null +++ b/api_examples/get_cast_single.json @@ -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" + } + ] + } +} diff --git a/api_examples/get_episodes.json b/api_examples/get_episodes.json new file mode 100644 index 0000000..04a66e9 --- /dev/null +++ b/api_examples/get_episodes.json @@ -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" + } + ] + } +} diff --git a/api_examples/get_media.json b/api_examples/get_media.json new file mode 100644 index 0000000..215f461 --- /dev/null +++ b/api_examples/get_media.json @@ -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 + } +} diff --git a/api_examples/get_media_single.json b/api_examples/get_media_single.json new file mode 100644 index 0000000..452d8b7 --- /dev/null +++ b/api_examples/get_media_single.json @@ -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"] + } + ] + } +} diff --git a/api_examples/get_tracks.json b/api_examples/get_tracks.json new file mode 100644 index 0000000..0fd536d --- /dev/null +++ b/api_examples/get_tracks.json @@ -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" + } + ] + } +} diff --git a/api_examples/update_adult_cast.json b/api_examples/update_adult_cast.json new file mode 100644 index 0000000..924d309 --- /dev/null +++ b/api_examples/update_adult_cast.json @@ -0,0 +1,8 @@ +{ + "name": "Jane Smith (Updated)", + "bio": "Updated bio", + "adult_specifics": { + "hair_color": "Red", + "weight": "56" + } +} diff --git a/api_examples/update_cast.json b/api_examples/update_cast.json new file mode 100644 index 0000000..f1c1676 --- /dev/null +++ b/api_examples/update_cast.json @@ -0,0 +1,4 @@ +{ + "name": "Tom Hardy (Updated)", + "bio": "Updated bio description" +} diff --git a/api_examples/update_episode.json b/api_examples/update_episode.json new file mode 100644 index 0000000..58485b3 --- /dev/null +++ b/api_examples/update_episode.json @@ -0,0 +1,4 @@ +{ + "title": "Updated Episode Title", + "description": "Updated description" +} diff --git a/api_examples/update_game.json b/api_examples/update_game.json new file mode 100644 index 0000000..3a13b0c --- /dev/null +++ b/api_examples/update_game.json @@ -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 + } + ] +} diff --git a/api_examples/update_media.json b/api_examples/update_media.json new file mode 100644 index 0000000..3ccbb08 --- /dev/null +++ b/api_examples/update_media.json @@ -0,0 +1,5 @@ +{ + "title": "The Matrix (Updated)", + "rating": 8.8, + "status": "Released" +} diff --git a/api_examples/update_track.json b/api_examples/update_track.json new file mode 100644 index 0000000..01c08ad --- /dev/null +++ b/api_examples/update_track.json @@ -0,0 +1,4 @@ +{ + "title": "Updated Track Title", + "duration": "4:00" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e369cc8 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/php-custom.ini b/php-custom.ini new file mode 100644 index 0000000..cf34b36 --- /dev/null +++ b/php-custom.ini @@ -0,0 +1,4 @@ +upload_max_filesize = 100M +post_max_size = 100M +memory_limit = 256M +max_execution_time = 300