From 04140786a7f92bb1868e6e216aaa1a0abc06240c Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Fri, 31 Oct 2025 00:24:17 +0100 Subject: [PATCH] Stuff i guess ? --- app/Controllers/AdminController.php | 533 ++++++++++++ app/Controllers/AdultController.php | 22 +- app/Controllers/Api/AuthController.php | 51 ++ app/Controllers/Api/PlayniteController.php | 569 ++++++++++++ app/Controllers/Api/_openapi.php | 41 + app/Controllers/DashboardController.php | 2 +- app/Controllers/GameController.php | 170 +++- app/Controllers/MovieController.php | 19 +- app/Controllers/SearchController.php | 21 +- app/Models/AdultVideo.php | 72 +- app/Models/Game.php | 87 +- app/Models/Movie.php | 185 +++- app/Models/TvShow.php | 201 ++++- app/Services/SteamGridDbService.php | 132 +++ composer.json | 9 +- config/database.php | 10 +- database/Migration.php | 16 + ...000000_create_adult_video_actor_tables.php | 30 + public/css/app.css | 118 +++ public/index.php | 78 +- public/js/adult-video-actors.js | 125 +++ resources/views/actor/index.twig | 29 +- resources/views/admin/adult/edit.twig | 233 +++++ resources/views/admin/adult/index.twig | 212 +++++ resources/views/admin/games/edit.twig | 737 ++++++++++++++++ resources/views/admin/games/index.twig | 218 +++++ resources/views/admin/layout.twig | 8 +- resources/views/admin/movies/edit.twig | 196 +++++ resources/views/admin/movies/index.twig | 253 ++++++ resources/views/admin/shows/edit.twig | 222 +++++ resources/views/admin/shows/index.twig | 202 +++++ resources/views/adult/index.twig | 43 +- resources/views/adult/show.twig | 2 +- resources/views/games/index.twig | 73 +- resources/views/games/show.twig | 818 ++++++++++-------- resources/views/layouts/app.twig | 15 +- resources/views/movies/index.twig | 101 ++- resources/views/search/index.twig | 14 +- routes/api.php | 24 + routes/web.php | 45 + 40 files changed, 5411 insertions(+), 525 deletions(-) create mode 100644 app/Controllers/Api/AuthController.php create mode 100644 app/Controllers/Api/PlayniteController.php create mode 100644 app/Controllers/Api/_openapi.php create mode 100644 app/Services/SteamGridDbService.php create mode 100644 database/Migration.php create mode 100644 database/migrations/2025_10_30_000000_create_adult_video_actor_tables.php create mode 100644 public/css/app.css create mode 100644 public/js/adult-video-actors.js create mode 100644 resources/views/admin/adult/edit.twig create mode 100644 resources/views/admin/adult/index.twig create mode 100644 resources/views/admin/games/edit.twig create mode 100644 resources/views/admin/games/index.twig create mode 100644 resources/views/admin/movies/edit.twig create mode 100644 resources/views/admin/movies/index.twig create mode 100644 resources/views/admin/shows/edit.twig create mode 100644 resources/views/admin/shows/index.twig create mode 100644 routes/api.php diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 896dcf8..8eb272a 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -40,6 +40,424 @@ class AdminController extends AdminBaseController ]); } + /** + * Media Management + */ + + // Movies Management + public function movies(Request $request, Response $response, $args) + { + $movieModel = new \App\Models\Movie($this->pdo); + + // Get query parameters with defaults + $page = max(1, (int)($request->getQueryParams()['page'] ?? 1)); + $search = trim($request->getQueryParams()['search'] ?? ''); + $genre = trim($request->getQueryParams()['genre'] ?? ''); + $director = trim($request->getQueryParams()['director'] ?? ''); + $sort = trim($request->getQueryParams()['sort'] ?? 'title_asc'); + $perPage = 20; + + // Prepare filters for the view + $filters = [ + 'search' => $search, + 'genre' => $genre, + 'director' => $director, + 'sort' => $sort + ]; + + // Get paginated and filtered movies + $movies = $movieModel->getPaginated( + $this->pdo, + $page, + $perPage, + $search, + $genre ? [$genre] : [], + $sort + ); + + // Get available genres and directors for filters + $genres = $movieModel->getGenres($this->pdo); + $directors = $movieModel->getDirectors($this->pdo); + + // Calculate pagination data + $totalMovies = $movieModel->getTotalCount( + $this->pdo, + $search, + $genre ? [$genre] : [] + ); + + $totalPages = max(1, ceil($totalMovies / $perPage)); + $currentPage = min($page, $totalPages); + + // Get flash messages if any + // $successMessages = $this->container->get('flash')->getMessage('success'); + + return $this->render($response, 'admin/movies/index.twig', [ + 'title' => 'Manage Movies', + 'movies' => $movies, + 'genres' => $genres, + 'directors' => $directors, + 'filters' => $filters, + 'pagination' => [ + 'current' => $currentPage, + 'total' => $totalPages, + 'per_page' => $perPage, + 'total_items' => $totalMovies, + 'from' => (($currentPage - 1) * $perPage) + 1, + 'to' => min($currentPage * $perPage, $totalMovies) + ] + + ]); + } + + public function editMovie(Request $request, Response $response, $args) + { + $id = $args['id'] ?? null; + $movieModel = new \App\Models\Movie($this->pdo); + + if ($request->getMethod() === 'POST') { + $data = $request->getParsedBody(); + + if ($id) { + // Update existing movie + $movieModel->update($id, $data); + //$this->flash->addMessage('success', 'Movie updated successfully'); + } else { + // Create new movie + $id = $movieModel->create($data); + // $this->flash->addMessage('success', 'Movie created successfully'); + } + + return $response->withHeader('Location', '/admin/movies/' . $id . '/edit') + ->withStatus(302); + } + + $movie = $id ? $movieModel->find($id) : null; + + return $this->render($response, 'admin/movies/edit.twig', [ + 'title' => $id ? 'Edit Movie' : 'Add New Movie', + 'movie' => $movie + ]); + } + + public function deleteMovie(Request $request, Response $response, $args) + { + $id = $args['id']; + $movieModel = new \App\Models\Movie($this->pdo); + $movieModel->delete($id); + + $this->flash->addMessage('success', 'Movie deleted successfully'); + return $response->withHeader('Location', '/admin/movies') + ->withStatus(302); + } + + // Games Management + public function games(Request $request, Response $response, $args) + { + $gameModel = new \App\Models\Game($this->pdo); + + // Get query parameters + $page = (int)($request->getQueryParams()['page'] ?? 1); + $search = $request->getQueryParams()['search'] ?? ''; + $platform = $request->getQueryParams()['platform'] ?? ''; + $genre = $request->getQueryParams()['genre'] ?? ''; + $isInstalled = $request->getQueryParams()['installed'] ?? ''; + $isFavorite = $request->getQueryParams()['favorite'] ?? ''; + $sort = $request->getQueryParams()['sort'] ?? 'title_asc'; + $perPage = 20; // Items per page + + // Prepare filters + $filters = [ + 'search' => $search, + 'platform' => $platform, + 'genre' => $genre, + 'is_installed' => $isInstalled, + 'is_favorite' => $isFavorite, + 'sort' => $sort + ]; + + // Get paginated and filtered games + $result = $gameModel->getGroupedGamesWithPagination( + $this->pdo, + $page, + $perPage, + $search, + $genre ? [$genre] : [], + $platform ? [$platform] : [] + ); + + // Get available platforms and genres for filters + $platforms = $gameModel->getPlatforms(); + $genres = $gameModel->getGenres(); + + // Calculate pagination data + $totalGames = $gameModel->getTotalCount( + $this->pdo, + $search, + $genre ? [$genre] : [], + $platform ? [$platform] : [] + ); + + $totalPages = ceil($totalGames / $perPage); + + return $this->render($response, 'admin/games/index.twig', [ + 'title' => 'Manage Games', + 'games' => $result, + 'platforms' => $platforms, + 'genres' => $genres, + 'filters' => $filters, + 'pagination' => [ + 'current' => $page, + 'total' => $totalPages, + 'per_page' => $perPage, + 'total_items' => $totalGames + ] + ]); + } + + public function editGame(Request $request, Response $response, $args) + { + $id = $args['id'] ?? null; + $gameModel = new \App\Models\Game($this->pdo); + + if ($request->getMethod() === 'POST') { + $data = $request->getParsedBody(); + + if ($id) { + $gameModel->update($id, $data); + } else { + $id = $gameModel->create($data); + } + + return $response->withHeader('Location', '/admin/games/' . $id . '/edit') + ->withStatus(302); + } + + $game = $id ? $gameModel->find($id) : null; + + return $this->render($response, 'admin/games/edit.twig', [ + 'title' => $id ? 'Edit Game' : 'Add New Game', + 'game' => $game + ]); + } + + public function deleteGame(Request $request, Response $response, $args) + { + $id = $args['id']; + $gameModel = new \App\Models\Game($this->pdo); + $gameModel->delete($id); + + return $response->withHeader('Location', '/admin/games') + ->withStatus(302); + } + + // TV Shows Management + public function shows(Request $request, Response $response, $args) + { + $showModel = new \App\Models\TvShow($this->pdo); + + // Get query parameters with defaults + $page = max(1, (int)($request->getQueryParams()['page'] ?? 1)); + $search = trim($request->getQueryParams()['search'] ?? ''); + $genre = trim($request->getQueryParams()['genre'] ?? ''); + $network = trim($request->getQueryParams()['network'] ?? ''); + $status = trim($request->getQueryParams()['status'] ?? ''); + $sort = trim($request->getQueryParams()['sort'] ?? 'name_asc'); + $perPage = 20; + + // Prepare filters for the view + $filters = [ + 'search' => $search, + 'genre' => $genre, + 'network' => $network, + 'status' => $status, + 'sort' => $sort + ]; + + // Get paginated and filtered shows + $shows = $showModel->getPaginated( + $this->pdo, + $page, + $perPage, + $search, + $genre ? [$genre] : [], + $network ? [$network] : [], + $status ? [$status] : [], + $sort + ); + + // Get available filters + $genres = $showModel->getGenres($this->pdo); + //$networks = $showModel->getNetworks($this->pdo); + $statuses = ['Returning Series', 'Ended', 'Canceled', 'In Production']; + + // Calculate pagination data + $totalShows = $showModel->getTotalCount( + $this->pdo, + $search, + $genre ? [$genre] : [], + $network ? [$network] : [], + $status ? [$status] : [] + ); + + $totalPages = max(1, ceil($totalShows / $perPage)); + $currentPage = min($page, $totalPages); + + return $this->render($response, 'admin/shows/index.twig', [ + 'title' => 'Manage TV Shows', + 'shows' => $shows, + 'genres' => $genres, + //'networks' => $networks, + 'statuses' => $statuses, + 'filters' => $filters, + 'pagination' => [ + 'current' => $currentPage, + 'total' => $totalPages, + 'per_page' => $perPage, + 'total_items' => $totalShows, + 'from' => (($currentPage - 1) * $perPage) + 1, + 'to' => min($currentPage * $perPage, $totalShows) + ] + ]); + } + + public function editShow(Request $request, Response $response, $args) + { + $id = $args['id'] ?? null; + $showModel = new \App\Models\TvShow($this->pdo); + + if ($request->getMethod() === 'POST') { + $data = $request->getParsedBody(); + + if ($id) { + $showModel->update($id, $data); + } else { + $id = $showModel->create($data); + } + + return $response->withHeader('Location', '/admin/shows/' . $id . '/edit') + ->withStatus(302); + } + + $show = $id ? $showModel->find($id) : null; + + return $this->render($response, 'admin/shows/edit.twig', [ + 'title' => $id ? 'Edit TV Show' : 'Add New TV Show', + 'show' => $show + ]); + } + + public function deleteShow(Request $request, Response $response, $args) + { + $id = $args['id']; + $showModel = new \App\Models\TvShow($this->pdo); + $showModel->delete($id); + + return $response->withHeader('Location', '/admin/shows') + ->withStatus(302); + } + + /** + * Display a listing of adult videos with pagination and filters + */ + public function adultVideos(Request $request, Response $response, $args) + { + $adultVideoModel = new \App\Models\AdultVideo($this->pdo); + + // Get query parameters with defaults + $page = max(1, (int)($request->getQueryParams()['page'] ?? 1)); + $search = trim($request->getQueryParams()['search'] ?? ''); + $genre = trim($request->getQueryParams()['genre'] ?? ''); + $director = trim($request->getQueryParams()['director'] ?? ''); + $sort = trim($request->getQueryParams()['sort'] ?? 'newest'); + $perPage = 20; + + // Prepare filters for the view + $filters = [ + 'search' => $search, + 'genre' => $genre, + 'director' => $director, + 'sort' => $sort + ]; + + // Get available filters + $genres = $adultVideoModel::getAvailableGenres($this->pdo); + $directors = $adultVideoModel::getAvailableDirectors($this->pdo); + + // Get paginated and filtered adult videos + $videos = $adultVideoModel::getAllWithPagination( + $this->pdo, + $page, + $perPage, + $search, + $genre ? [$genre] : [], + $director ? [$director] : [] + ); + + // Get total count for pagination + $totalVideos = $adultVideoModel::getTotalCount( + $this->pdo, + $search, + $genre ? [$genre] : [], + $director ? [$director] : [] + ); + + $totalPages = max(1, ceil($totalVideos / $perPage)); + $currentPage = min($page, $totalPages); + + return $this->render($response, 'admin/adult/index.twig', [ + 'title' => 'Manage Adult Videos', + 'videos' => $videos, + 'genres' => $genres, + 'directors' => $directors, + 'filters' => $filters, + 'pagination' => [ + 'current' => $currentPage, + 'total' => $totalPages, + 'per_page' => $perPage, + 'total_items' => $totalVideos, + 'from' => (($currentPage - 1) * $perPage) + 1, + 'to' => min($currentPage * $perPage, $totalVideos) + ] + ]); + } + + public function editAdultVideo(Request $request, Response $response, $args) + { + $id = $args['id'] ?? null; + $adultModel = new \App\Models\AdultVideo($this->pdo); + + if ($request->getMethod() === 'POST') { + $data = $request->getParsedBody(); + + if ($id) { + $adultModel->update($id, $data); + } else { + $id = $adultModel->create($data); + } + + return $response->withHeader('Location', '/admin/adult/' . $id . '/edit') + ->withStatus(302); + } + + $video = $id ? $adultModel->find($id) : null; + + return $this->render($response, 'admin/adult/edit.twig', [ + 'title' => $id ? 'Edit Adult Video' : 'Add New Adult Video', + 'video' => $video + ]); + } + + public function deleteAdultVideo(Request $request, Response $response, $args) + { + $id = $args['id']; + $adultModel = new \App\Models\AdultVideo($this->pdo); + $adultModel->delete($id); + + return $response->withHeader('Location', '/admin/adult') + ->withStatus(302); + } + public function syncSource(Request $request, Response $response, $args) { $sourceId = $args['id']; @@ -202,4 +620,119 @@ class AdminController extends AdminBaseController 'message' => 'Sync process starting in background' ]); } + + /** + * Get actors for a specific adult video + */ + public function getAdultVideoActors(Request $request, Response $response, $args) + { + $adultVideo = new \App\Models\AdultVideo($this->pdo); + $video = $adultVideo->find($args['id']); + + if (!$video) { + $response->getBody()->write(json_encode(['error' => 'Video not found'])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + $actors = $adultVideo->actors($args['id']); + $response->getBody()->write(json_encode(['data' => $actors])); + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * Add an actor to an adult video + */ + public function addActorToAdultVideo(Request $request, Response $response, $args) + { + $contentType = $request->getHeaderLine('Content-Type'); + + if (strstr($contentType, 'application/json')) { + $data = json_decode((string)$request->getBody(), true); + } else { + $data = $request->getParsedBody(); + } + + $actorId = $data['actor_id'] ?? null; + + if (!$actorId) { + $response->getBody()->write(json_encode(['error' => 'Actor ID is required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $adultVideo = new \App\Models\AdultVideo($this->pdo); + $video = $adultVideo->find($args['id']); + + if (!$video) { + $response->getBody()->write(json_encode(['error' => 'Video not found'])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + $success = $adultVideo->addActor($actorId); + + if ($success) { + $adultVideo->updateCastField(); + $response->getBody()->write(json_encode([ + 'success' => true, + 'message' => 'Actor added successfully' + ])); + return $response->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write(json_encode(['error' => 'Failed to add actor'])); + return $response->withStatus(500)->withHeader('Content-Type', 'application/json'); + } + + /** + * Remove an actor from an adult video + */ + public function removeActorFromAdultVideo(Request $request, Response $response, $args) + { + $actorId = $args['actorId'] ?? null; + + if (!$actorId) { + $response->getBody()->write(json_encode(['error' => 'Actor ID is required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $adultVideo = new \App\Models\AdultVideo($this->pdo); + $video = $adultVideo->find($args['id']); + + if (!$video) { + $response->getBody()->write(json_encode(['error' => 'Video not found'])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + $success = $adultVideo->removeActor($actorId); + + if ($success) { + $adultVideo->updateCastField(); + $response->getBody()->write(json_encode([ + 'success' => true, + 'message' => 'Actor removed successfully' + ])); + return $response->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write(json_encode(['error' => 'Failed to remove actor'])); + return $response->withStatus(500)->withHeader('Content-Type', 'application/json'); + } + + /** + * Search actors by name + */ + public function searchActors(Request $request, Response $response, $args) + { + $query = $request->getQueryParams()['q'] ?? ''; + + if (empty($query)) { + $response->getBody()->write(json_encode(['data' => []])); + return $response->withHeader('Content-Type', 'application/json'); + } + + $adultVideo = new \App\Models\AdultVideo($this->pdo); + $actors = $adultVideo->searchActors($this->pdo, $query); + + $response->getBody()->write(json_encode(['data' => $actors])); + return $response->withHeader('Content-Type', 'application/json'); + } } diff --git a/app/Controllers/AdultController.php b/app/Controllers/AdultController.php index 45fc00f..719b577 100644 --- a/app/Controllers/AdultController.php +++ b/app/Controllers/AdultController.php @@ -41,11 +41,12 @@ class AdultController extends Controller } $directors = array_filter($directors); - // Get view mode + // Get view mode and sort $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + $sort = $queryParams['sort'] ?? 'recent'; - // Get adult videos with pagination and filters - $adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors); + // Get adult videos with pagination, filters, and sorting + $adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors, $sort); // Process metadata to extract local image paths for template compatibility foreach ($adultVideos as &$video) { @@ -94,6 +95,21 @@ class AdultController extends Controller 'search' => $search, 'view_mode' => $viewMode, 'view_modes' => ['grid', 'list', 'covers'], + 'sort' => $sort, + 'sort_options' => [ + 'recent' => 'Most Recent', + 'oldest' => 'Oldest First', + 'title_asc' => 'Title (A-Z)', + 'title_desc' => 'Title (Z-A)', + 'year_asc' => 'Release Year (Oldest First)', + 'year_desc' => 'Release Year (Newest First)', + 'rating_desc' => 'Highest Rated', + 'rating_asc' => 'Lowest Rated', + 'views_desc' => 'Most Viewed', + 'views_asc' => 'Least Viewed', + 'runtime_desc' => 'Longest Runtime', + 'runtime_asc' => 'Shortest Runtime', + ], 'filters' => [ 'genres' => $genres, 'directors' => $directors diff --git a/app/Controllers/Api/AuthController.php b/app/Controllers/Api/AuthController.php new file mode 100644 index 0000000..e50230d --- /dev/null +++ b/app/Controllers/Api/AuthController.php @@ -0,0 +1,51 @@ +authService = $authService; + } + + /** + * Check if user is authenticated (API endpoint) + */ + public function checkAuth(Request $request, Response $response, $args) + { + try { + if (!$this->authService->isLoggedIn()) { + return $this->jsonResponse($response->withStatus(401), [ + 'error' => '401 Forbidden' + ]); + } + + $user = $this->authService->getCurrentUser(); + if (!$user) { + return $this->jsonResponse($response->withStatus(401), [ + 'error' => '401 Forbidden' + ]); + } + + return $this->jsonResponse($response, [ + 'id' => $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'is_admin' => $this->authService->isAdmin() + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => 'Authentication check failed' + ]); + } + } +} diff --git a/app/Controllers/Api/PlayniteController.php b/app/Controllers/Api/PlayniteController.php new file mode 100644 index 0000000..e2c0395 --- /dev/null +++ b/app/Controllers/Api/PlayniteController.php @@ -0,0 +1,569 @@ +pdo = $pdo; + $this->importService = new PlayniteImportService($pdo); + } + + + /** + * @OA\Post( + * path="/playnite/insert", + * summary="Insert or update games from Playnite", + * tags={"Playnite"}, + * operationId="insertGames", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"games"}, + * @OA\Property( + * property="games", + * type="array", + * @OA\Items(type="object") + * ), + * @OA\Property( + * property="update_existing", + * type="boolean", + * default=true, + * description="Whether to update existing games" + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Games successfully imported/updated", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean"), + * @OA\Property(property="result", type="object") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * @param Request $request + * @param Response $response + * @param array $args + * @return Response + */ + public function insertGames(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + + if (!isset($data['games']) || !is_array($data['games'])) { + return $this->jsonResponse($response->withStatus(400), [ + 'error' => 'Games data is required' + ]); + } + + try { + $importResult = $this->importService->importGames($data['games'], true); + + return $this->jsonResponse($response, [ + 'success' => true, + 'result' => $importResult + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * @OA\Put( + * path="/playnite/update", + * summary="Update existing games from Playnite", + * tags={"Playnite"}, + * operationId="updateGames", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"games"}, + * @OA\Property( + * property="games", + * type="array", + * @OA\Items(type="object") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Games successfully updated", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean"), + * @OA\Property(property="result", type="object") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * @param Request $request + * @param Response $response + * @param array $args + * @return Response + */ + public function updateGames(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + + if (!isset($data['games']) || !is_array($data['games'])) { + return $this->jsonResponse($response->withStatus(400), [ + 'error' => 'Games data is required' + ]); + } + + try { + $importResult = $this->importService->importGames($data['games'], true); + + return $this->jsonResponse($response, [ + 'success' => true, + 'result' => $importResult + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * @OA\Delete( + * path="/playnite/delete", + * summary="Delete games from Playnite", + * tags={"Playnite"}, + * operationId="deleteGames", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"games"}, + * @OA\Property( + * property="games", + * type="array", + * @OA\Items(type="object") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Games successfully deleted", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean"), + * @OA\Property(property="result", type="object", + * @OA\Property(property="deleted", type="integer"), + * @OA\Property(property="errors", type="array", @OA\Items(type="string")) + * ) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * @param Request $request + * @param Response $response + * @param array $args + * @return Response + */ + public function deleteGames(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + + if (!isset($data['games']) || !is_array($data['games'])) { + return $this->jsonResponse($response->withStatus(400), [ + 'error' => 'Games data is required' + ]); + } + + try { + $results = [ + 'deleted' => 0, + 'errors' => [] + ]; + + foreach ($data['games'] as $gameData) { + try { + // Find the game by platform_game_id and source_id + $existingGame = $this->findExistingGame($gameData); + + if ($existingGame) { + $this->deleteGame($existingGame['id']); + $results['deleted']++; + } + } catch (\Exception $e) { + $results['errors'][] = "Failed to delete {$gameData['title']}: " . $e->getMessage(); + } + } + + return $this->jsonResponse($response, [ + 'success' => true, + 'result' => $results + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * @OA\Post( + * path="/playnite/upload-images", + * summary="Upload game images from Playnite", + * tags={"Playnite"}, + * operationId="uploadImages", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * oneOf={ + * @OA\Schema( + * @OA\Property(property="name", type="string"), + * @OA\Property(property="cover", type="string", format="byte"), + * @OA\Property(property="icon", type="string", format="byte"), + * @OA\Property(property="background", type="string", format="byte") + * ), + * @OA\Schema( + * @OA\Property( + * property="games", + * type="array", + * @OA\Items( + * @OA\Property(property="name", type="string"), + * @OA\Property(property="cover", type="string", format="byte"), + * @OA\Property(property="icon", type="string", format="byte"), + * @OA\Property(property="background", type="string", format="byte") + * ) + * ) + * ) + * } + * ) + * ), + * @OA\Response( + * response=200, + * description="Images successfully uploaded", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean"), + * @OA\Property(property="result", type="object", + * @OA\Property(property="uploaded", type="integer"), + * @OA\Property(property="errors", type="array", @OA\Items(type="string")) + * ) + * ) + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * @param Request $request + * @param Response $response + * @param array $args + * @return Response + */ + public function uploadImages(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + + try { + $results = [ + 'uploaded' => 0, + 'errors' => [] + ]; + + // Handle image uploads based on the format expected by the plugin + if (isset($data['name']) && isset($data['cover'])) { + // Single game image upload + $result = $this->handleImageUpload($data); + if ($result) { + $results['uploaded']++; + } + } elseif (isset($data['games']) && is_array($data['games'])) { + // Multiple games with images + foreach ($data['games'] as $gameData) { + $result = $this->handleImageUpload($gameData); + if ($result) { + $results['uploaded']++; + } + } + } + + return $this->jsonResponse($response, [ + 'success' => true, + 'result' => $results + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * Handle individual image upload + */ + private function handleImageUpload(array $gameData): bool + { + try { + // For now, we'll just validate the data format + // In a real implementation, you might want to save the images to disk + // and update the game records with the image paths + + $name = $gameData['name'] ?? ''; + $cover = $gameData['cover'] ?? ''; + $icon = $gameData['icon'] ?? ''; + $background = $gameData['background'] ?? ''; + + // Validate base64 images + if ($cover && !preg_match('/^data:image\/(jpeg|png|gif|webp);base64,/', $cover)) { + throw new \Exception("Invalid cover image format"); + } + + // Here you would typically: + // 1. Decode base64 images + // 2. Save them to the filesystem + // 3. Update the game record with the image paths + + return true; + + } catch (\Exception $e) { + error_log("Image upload failed for game {$name}: " . $e->getMessage()); + return false; + } + } + + /** + * Find existing game by platform_game_id and source_id + */ + private function findExistingGame(array $gameData): ?array + { + $stmt = $this->pdo->prepare(" + SELECT id, title, platform_game_id, source_id + FROM games + WHERE platform_game_id = :platform_game_id AND source_id = :source_id + "); + $stmt->execute([ + 'platform_game_id' => $gameData['platform_game_id'], + 'source_id' => $gameData['source_id'] + ]); + + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + /** + * Delete game + */ + private function deleteGame(int $gameId): void + { + $stmt = $this->pdo->prepare("DELETE FROM games WHERE id = :id"); + $stmt->execute(['id' => $gameId]); + } + + /** + * Transform Playnite game data to internal format + */ + private function transformPlayniteGame(array $game): array + { + // Find or create source + $source = $this->findOrCreateSource($game); + + // Transform the game data similar to PlayniteImportService + return [ + 'title' => $game['Name'] ?? $game['name'] ?? '', + 'game_key' => $this->generateGameKey($game['Name'] ?? $game['name'] ?? ''), + 'description' => $this->cleanHtml($game['Description'] ?? $game['description'] ?? ''), + 'platform_game_id' => $game['GameId'] ?? $game['game_id'] ?? '', + 'platform' => $this->extractPlatformFromPlaynite($game), + 'source_id' => $source['id'], + + // Rich media + 'background_image' => $game['BackgroundImage'] ?? $game['background'] ?? null, + 'cover_image' => $game['CoverImage'] ?? $game['cover'] ?? null, + 'icon' => $game['Icon'] ?? $game['icon'] ?? null, + + // Play statistics + 'playtime_minutes' => $this->parsePlaytime($game['Playtime'] ?? $game['playtime'] ?? 0), + 'play_count' => $game['PlayCount'] ?? $game['play_count'] ?? 0, + + // Enhanced ratings + 'rating' => $this->normalizeRating($game['CriticScore'] ?? $game['critic_score'] ?? null), + 'critic_score' => $game['CriticScore'] ?? $game['critic_score'] ?? null, + 'community_score' => $game['CommunityScore'] ?? $game['community_score'] ?? null, + 'user_score' => $game['UserScore'] ?? $game['user_score'] ?? null, + + // Timestamps + 'added_at' => isset($game['Added']) ? date('Y-m-d H:i:s', strtotime($game['Added'])) : null, + 'modified_at' => isset($game['Modified']) ? date('Y-m-d H:i:s', strtotime($game['Modified'])) : null, + 'last_played_at' => isset($game['LastActivity']) ? date('Y-m-d H:i:s', strtotime($game['LastActivity'])) : null, + + // Playnite metadata + 'metadata' => json_encode([ + 'playnite_id' => $game['Id'] ?? $game['playnite_id'] ?? null, + 'version' => $game['Version'] ?? $game['version'] ?? null, + 'hidden' => $this->toBoolean($game['Hidden'] ?? $game['hidden'] ?? false), + 'notes' => $game['Notes'] ?? $game['notes'] ?? null, + 'manual' => $game['Manual'] ?? $game['manual'] ?? null, + 'pre_script' => $game['PreScript'] ?? $game['pre_script'] ?? null, + 'post_script' => $game['PostScript'] ?? $game['post_script'] ?? null, + 'game_started_script' => $game['GameStartedScript'] ?? $game['game_started_script'] ?? null, + 'use_global_scripts' => [ + 'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? $game['use_global_pre_script'] ?? true), + 'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? $game['use_global_post_script'] ?? true), + 'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? $game['use_global_game_started_script'] ?? true) + ] + ]) + ]; + } + + /** + * Find or create a source for the game + */ + private function findOrCreateSource(array $game): array + { + $sourceName = $game['Source']['Name'] ?? $game['source'] ?? 'Playnite'; + $sourceId = $game['Source']['Id'] ?? $game['source_id'] ?? null; + + // Try to find existing source + $stmt = $this->pdo->prepare("SELECT id, display_name FROM sources WHERE display_name = :name OR id = :source_id"); + $stmt->execute([ + 'name' => $sourceName, + 'source_id' => $sourceId + ]); + + $source = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$source) { + // Create new source + $stmt = $this->pdo->prepare("INSERT INTO sources (display_name, created_at, updated_at) VALUES (:name, NOW(), NOW())"); + $stmt->execute(['name' => $sourceName]); + $source = ['id' => $this->pdo->lastInsertId(), 'display_name' => $sourceName]; + } + + return $source; + } + + /** + * Generate a consistent game key for grouping + */ + private function generateGameKey(string $title): string + { + return \App\Models\Game::generateGameKey($title); + } + + /** + * Extract platform from Playnite data + */ + private function extractPlatformFromPlaynite(array $game): string + { + if (isset($game['Platforms']) && is_array($game['Platforms'])) { + $platformNames = array_map(function($platform) { + return $platform['Name'] ?? 'Unknown'; + }, $game['Platforms']); + + return implode(', ', $platformNames); + } + + if (isset($game['Platform']) && is_array($game['Platform'])) { + return $game['Platform']['Name'] ?? 'PC'; + } + + return $game['platform'] ?? 'PC'; + } + + /** + * Parse playtime from Playnite format (usually in seconds) + */ + private function parsePlaytime($playtime): int + { + if (is_numeric($playtime)) { + return (int)($playtime / 60); // Convert seconds to minutes + } + return 0; + } + + /** + * Normalize rating to 0-10 scale + */ + private function normalizeRating($rating): ?float + { + if (is_numeric($rating)) { + $rating = (float)$rating; + // If rating is 0-100 scale, convert to 0-10 + if ($rating > 10) { + return $rating / 10; + } + return $rating; + } + return null; + } + + /** + * Clean HTML from description + */ + private function cleanHtml(?string $html): ?string + { + if (!$html) { + return null; + } + + // Remove HTML tags but keep basic formatting + $text = strip_tags($html); + // Decode HTML entities + $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); + // Clean up extra whitespace + $text = preg_replace('/\s+/', ' ', $text); + return trim($text); + } + + /** + * Convert a value to boolean + */ + private function toBoolean($value): bool + { + if ($value === null || $value === false || $value === 0 || $value === '0') { + return false; + } + if ($value === true || $value === 1 || $value === '1') { + return true; + } + if (is_string($value)) { + return !empty(trim($value)); + } + return (bool) $value; + } +} diff --git a/app/Controllers/Api/_openapi.php b/app/Controllers/Api/_openapi.php new file mode 100644 index 0000000..79e90e9 --- /dev/null +++ b/app/Controllers/Api/_openapi.php @@ -0,0 +1,41 @@ + $recentGames, 'recent_movies' => $recentMovies, 'recent_syncs' => $recentSyncs, - 'sync_stats' => $syncStats + //'sync_stats' => $syncStats ]); } } diff --git a/app/Controllers/GameController.php b/app/Controllers/GameController.php index dee3c59..2a830fc 100644 --- a/app/Controllers/GameController.php +++ b/app/Controllers/GameController.php @@ -5,16 +5,164 @@ namespace App\Controllers; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use App\Models\Game; +use App\Services\SteamGridDbService; use Slim\Views\Twig; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; class GameController extends Controller { private \PDO $pdo; + private SteamGridDbService $steamGridDb; public function __construct(\PDO $pdo, Twig $view) { parent::__construct($view); $this->pdo = $pdo; + $this->steamGridDb = new SteamGridDbService(); + } + + /** + * Search for games on SteamGridDB + */ + public function searchSteamGridDb(Request $request, Response $response, array $args): Response + { + $query = $request->getQueryParams()['q'] ?? ''; + $results = []; + + if (!empty($query)) { + $results = $this->steamGridDb->searchGames($query); + } + + return $this->json($response, [ + 'success' => true, + 'data' => $results + ]); + } + + /** + * Get media from SteamGridDB + */ + public function getSteamGridDbMedia(Request $request, Response $response, array $args): Response + { + $gameId = $args['gameId'] ?? null; + $type = $args['type'] ?? 'grids'; + $media = []; + + if ($gameId) { + switch ($type) { + case 'grids': + $media = $this->steamGridDb->getGrids($gameId, [ + 'styles' => ['alternate', 'blurred', 'white_logo', 'material', 'no_logo'], + 'dimensions' => ['600x900', '920x430', '460x215', '920x430'] + ]); + break; + case 'heroes': + $media = $this->steamGridDb->getHeroes($gameId, [ + 'dimensions' => ['1920x620', '3840x1240'] + ]); + break; + case 'icons': + $media = $this->steamGridDb->getIcons($gameId, [ + 'dimensions' => ['32x32', '64x64', '128x128', '256x256', '512x512'] + ]); + break; + case 'logos': + $media = $this->steamGridDb->getLogos($gameId); + break; + } + } + + return $this->json($response, [ + 'success' => true, + 'data' => $media + ]); + } + + /** + * Download and set media from SteamGridDB + */ + public function setSteamGridDbMedia(Request $request, Response $response, array $args): Response + { + $gameId = $args['id'] ?? null; + $data = $request->getParsedBody(); + $type = $data['type'] ?? ''; + $url = $data['url'] ?? ''; + $field = ''; + + if (!$gameId || !$type || !$url) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Missing required parameters' + ], 400); + } + + // Map media type to database field + switch ($type) { + case 'grid': + $field = 'image_url'; + break; + case 'hero': + $field = 'banner_url'; + break; + case 'icon': + $field = 'icon'; + break; + case 'logo': + $field = 'logo_url'; + break; + default: + return $this->json($response, [ + 'success' => false, + 'message' => 'Invalid media type' + ], 400); + } + + // Download the media file + $tempFile = $this->steamGridDb->downloadMedia($url); + if (!$tempFile) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Failed to download media' + ], 500); + } + + // Move the file to the appropriate location + $uploadDir = __DIR__ . '/../../public/uploads/games/' . $gameId; + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + $filename = $type . '_' . uniqid() . '.' . pathinfo($url, PATHINFO_EXTENSION); + $filepath = $uploadDir . '/' . $filename; + $publicPath = '/uploads/games/' . $gameId . '/' . $filename; + + if (!rename($tempFile, $filepath)) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Failed to save media file' + ], 500); + } + + // Update the game record + $game = Game::find($this->pdo, $gameId); + if (!$game) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Game not found' + ], 404); + } + + $game->{$field} = $publicPath; + $game->save($this->pdo); + + return $this->json($response, [ + 'success' => true, + 'data' => [ + 'url' => $publicPath, + 'field' => $field + ] + ]); } public function index(Request $request, Response $response, $args) @@ -44,8 +192,11 @@ class GameController extends Controller // Get view mode $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers - // Get games with pagination and filters - $games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms); + // Get sort parameter + $sort = $queryParams['sort'] ?? 'title_asc'; + + // Get games with pagination, filters, and sorting + $games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms, $sort); // Get total count for pagination $totalCount = Game::getTotalCount($this->pdo, $search, $genres, $platforms); @@ -82,6 +233,17 @@ class GameController extends Controller 'available_filters' => [ 'genres' => $availableGenres, 'platforms' => $availablePlatforms + ], + 'sort' => $sort, + 'sort_options' => [ + 'title_asc' => 'Title (A-Z)', + 'title_desc' => 'Title (Z-A)', + 'year_asc' => 'Release Year (Oldest First)', + 'year_desc' => 'Release Year (Newest First)', + 'playtime_desc' => 'Most Played', + 'completion_desc' => 'Highest Completion', + 'added_desc' => 'Recently Added', + 'last_played_desc' => 'Last Played' ] ]); } @@ -92,9 +254,9 @@ class GameController extends Controller // Find the main game entry (could be any platform version) $stmt = $this->pdo->prepare(" - SELECT g.*, s.display_name as source_name + SELECT g.*, g.platform as source_name FROM games g - JOIN sources s ON g.source_id = s.id + WHERE g.game_key = :game_key LIMIT 1 "); diff --git a/app/Controllers/MovieController.php b/app/Controllers/MovieController.php index 32d71a9..55b3e93 100644 --- a/app/Controllers/MovieController.php +++ b/app/Controllers/MovieController.php @@ -44,8 +44,11 @@ class MovieController extends Controller // Get view mode $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers - // Get movies with pagination and filters - $movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors); + // Get sort parameter + $sort = $queryParams['sort'] ?? 'title_asc'; + + // Get movies with pagination, filters, and sorting + $movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors, $sort); // Get total count for pagination $totalCount = Movie::getTotalCount($this->pdo, $search, $genres, $directors); @@ -82,6 +85,18 @@ class MovieController extends Controller 'available_filters' => [ 'genres' => $availableGenres, 'directors' => $availableDirectors + ], + 'sort' => $sort, + 'sort_options' => [ + 'title_asc' => 'Title (A-Z)', + 'title_desc' => 'Title (Z-A)', + 'year_asc' => 'Release Year (Oldest First)', + 'year_desc' => 'Release Year (Newest First)', + 'rating_desc' => 'Highest Rated', + 'views_desc' => 'Most Viewed', + 'added_desc' => 'Recently Added', + 'added_asc' => 'Oldest Added', + 'last_watched_desc' => 'Last Watched' ] ]); } diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index be54cde..463fd79 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -33,32 +33,27 @@ class SearchController extends Controller $results = []; // Search movies (including adult videos) - $movieStmt = $this->pdo->prepare(" + $searchTerm = $this->pdo->quote("%$search%"); + $movieStmt = $this->pdo->query(" SELECT m.*, s.display_name as source_name, 'movie' as type FROM movies m JOIN sources s ON m.source_id = s.id - WHERE (m.title LIKE :search OR m.overview LIKE :search) + WHERE (m.title LIKE $searchTerm OR m.overview LIKE $searchTerm) ORDER BY m.title LIMIT 20 "); - $searchParam = "%{$search}%"; - $movieStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR); - $movieStmt->execute(); $results['movies'] = $movieStmt->fetchAll(\PDO::FETCH_ASSOC); // Search games - $gameStmt = $this->pdo->prepare(" - SELECT g.*, s.display_name as source_name, 'game' as type + $gameStmt = $this->pdo->query(" + SELECT g.*, 'game' as type FROM games g - JOIN sources s ON g.source_id = s.id - WHERE (g.name LIKE :search OR g.description LIKE :search) - ORDER BY g.name + WHERE (g.title LIKE $searchTerm OR g.description LIKE $searchTerm) + ORDER BY g.title LIMIT 20 "); - $gameStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR); - $gameStmt->execute(); $results['games'] = $gameStmt->fetchAll(\PDO::FETCH_ASSOC); - + return $this->view->render($response, 'search/index.twig', [ 'title' => 'Search Results', 'search' => $search, diff --git a/app/Models/AdultVideo.php b/app/Models/AdultVideo.php index e6cdc0c..f427ecd 100644 --- a/app/Models/AdultVideo.php +++ b/app/Models/AdultVideo.php @@ -25,7 +25,7 @@ class AdultVideo extends Model 'external_id' ]; - public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array + public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = [], string $sort = 'recent'): array { $offset = ($page - 1) * $perPage; @@ -61,7 +61,24 @@ class AdultVideo extends Model $sql .= $whereClause . " av.director IN (" . implode(',', $placeholders) . ")"; } - $sql .= " ORDER BY av.created_at DESC LIMIT :limit OFFSET :offset"; + // Add sorting + $sortOptions = [ + 'recent' => 'av.created_at DESC', + 'oldest' => 'av.created_at ASC', + 'title_asc' => 'av.title ASC', + 'title_desc' => 'av.title DESC', + 'year_asc' => 'av.release_date ASC', + 'year_desc' => 'av.release_date DESC', + 'rating_asc' => 'av.rating ASC', + 'rating_desc' => 'av.rating DESC', + 'views_asc' => 'av.watch_count ASC', + 'views_desc' => 'av.watch_count DESC', + 'runtime_asc' => 'av.runtime_minutes ASC', + 'runtime_desc' => 'av.runtime_minutes DESC', + ]; + + $sortOrder = $sortOptions[$sort] ?? $sortOptions['recent']; + $sql .= " ORDER BY " . $sortOrder . " LIMIT :limit OFFSET :offset"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); @@ -143,7 +160,7 @@ class AdultVideo extends Model /** * Get all actors associated with this adult video */ - public function actors() + public function actors($args) { $stmt = $this->pdo->prepare(" SELECT a.* @@ -152,7 +169,7 @@ class AdultVideo extends Model WHERE aav.adult_video_id = :adult_video_id ORDER BY a.name ASC "); - $stmt->execute(['adult_video_id' => $this->id]); + $stmt->execute(['adult_video_id' => $args]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } @@ -200,21 +217,6 @@ class AdultVideo extends Model ]); } - /** - * Get TV show statistics - */ - public static function getStats(\PDO $pdo): array - { - $stmt = $pdo->query(" - SELECT - COUNT(*) as total_adult_videos, - COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos, - AVG(rating) as avg_rating - FROM adult_videos - "); - return $stmt->fetch(\PDO::FETCH_ASSOC); - } - /** * Get available genres for filtering */ @@ -242,4 +244,36 @@ class AdultVideo extends Model "); return $stmt->fetchAll(\PDO::FETCH_COLUMN); } + + /** + * Get TV show statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_adult_videos, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos, + AVG(rating) as avg_rating + FROM adult_videos + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * Search actors by name + */ + public static function searchActors(\PDO $pdo, string $query): array + { + $stmt = $pdo->prepare(" + SELECT a.* + FROM actors a + WHERE a.name LIKE :query + ORDER BY a.name + LIMIT 10 + "); + + $stmt->execute(['query' => "%{$query}%"]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } } diff --git a/app/Models/Game.php b/app/Models/Game.php index ac3fdc6..0c6ecdc 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -177,9 +177,9 @@ class Game extends Model } $stmt = $this->pdo->prepare(" - SELECT g.*, s.display_name as source_name + SELECT g.*, g.platform as source_name FROM games g - JOIN sources s ON g.source_id = s.id + WHERE g.game_key = :game_key ORDER BY g.platform, g.source_id "); @@ -215,9 +215,9 @@ class Game extends Model // Enhance each game with platform details foreach ($games as &$game) { - $game['platforms'] = array_unique(explode(',', $game['platforms'])); - $game['source_ids'] = array_unique(explode(',', $game['source_ids'])); - $game['genres'] = array_unique(array_filter(explode(',', $game['genres']))); + $game['platforms'] = !empty($game['platforms']) ? array_unique(explode(',', $game['platforms'])) : []; + $game['source_ids'] = !empty($game['source_ids']) ? array_unique(explode(',', $game['source_ids'])) : []; + $game['genres'] = !empty($game['genres']) ? array_unique(array_filter(explode(',', $game['genres']))) : []; } return $games; @@ -298,7 +298,7 @@ class Game extends Model /** * Get grouped games with pagination and search support */ - public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $platforms = []): array + public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $platforms = [], string $sort = 'title_asc'): array { $offset = ($page - 1) * $perPage; @@ -313,7 +313,9 @@ class Game extends Model MAX(last_played_at) as last_played_at, SUM(playtime_minutes) as total_playtime, MAX(completion_percentage) as max_completion, - GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres + GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres, + MAX(release_date) as release_date, + MAX(added_at) as added_at FROM games WHERE game_key IS NOT NULL "; @@ -343,7 +345,24 @@ class Game extends Model $sql .= " AND platform IN (" . implode(',', $placeholders) . ")"; } - $sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC LIMIT :limit OFFSET :offset"; + // Add sorting + $sortOptions = [ + 'title_asc' => 'title ASC', + 'title_desc' => 'title DESC', + 'year_asc' => 'release_date ASC NULLS LAST', + 'year_desc' => 'release_date DESC NULLS LAST', + 'playtime_asc' => 'total_playtime ASC', + 'playtime_desc' => 'total_playtime DESC', + 'completion_asc' => 'max_completion ASC NULLS LAST', + 'completion_desc' => 'max_completion DESC NULLS LAST', + 'added_asc' => 'added_at ASC NULLS LAST', + 'added_desc' => 'added_at DESC NULLS LAST', + 'last_played_asc' => 'last_played_at ASC NULLS LAST', + 'last_played_desc' => 'last_played_at DESC NULLS LAST' + ]; + + $sortClause = $sortOptions[$sort] ?? 'title ASC'; + $sql .= " GROUP BY game_key, title ORDER BY $sortClause LIMIT :limit OFFSET :offset"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); @@ -358,20 +377,52 @@ class Game extends Model // Enhance each game with platform details foreach ($games as &$game) { - $game['platforms'] = array_unique(explode(',', $game['platforms'])); - $game['source_ids'] = array_unique(explode(',', $game['source_ids'])); - $game['genres'] = array_unique(array_filter(explode(',', $game['genres']))); + $game['platforms'] = !empty($game['platforms']) ? array_unique(explode(',', $game['platforms'])) : []; + $game['source_ids'] = !empty($game['source_ids']) ? array_unique(explode(',', $game['source_ids'])) : []; + $game['genres'] = !empty($game['genres']) ? array_unique(array_filter(explode(',', $game['genres']))) : []; } return $games; } /** - * Get Playnite-specific genres + * Get all unique genres from the games table + * Combines both Playnite JSON genres and regular genre field */ public function getGenres(): array { - return $this->genres_json ?? []; + // First get genres from the regular genre field + $stmt = $this->pdo->query("SELECT DISTINCT genre FROM {$this->table} WHERE genre IS NOT NULL AND genre != ''"); + $genres = []; + $results = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + // Flatten and deduplicate genres + foreach ($results as $genreList) { + $genreArray = array_map('trim', explode(',', $genreList)); + $genres = array_merge($genres, $genreArray); + } + + // Also get genres from Playnite JSON data + $stmt = $this->pdo->query("SELECT genres_json FROM {$this->table} WHERE genres_json IS NOT NULL AND genres_json != '[]'"); + $jsonGenres = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + foreach ($jsonGenres as $json) { + $decoded = json_decode($json, true); + if (is_array($decoded)) { + foreach ($decoded as $genre) { + if (is_array($genre) && isset($genre['Name'])) { + $genres[] = $genre['Name']; + } elseif (is_string($genre)) { + $genres[] = $genre; + } + } + } + } + + $genres = array_unique($genres); + sort($genres); + + return array_values(array_filter($genres)); } /** @@ -397,6 +448,16 @@ class Game extends Model { return $this->tags_json ?? []; } + + /** + * Get all unique platforms from the games table + */ + public function getPlatforms(): array + { + $stmt = $this->pdo->query("SELECT DISTINCT platform FROM {$this->table} WHERE platform IS NOT NULL AND platform != '' ORDER BY platform"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + /** * Get Playnite-specific features diff --git a/app/Models/Movie.php b/app/Models/Movie.php index a67822a..01d02bc 100644 --- a/app/Models/Movie.php +++ b/app/Models/Movie.php @@ -46,6 +46,150 @@ class Movie extends Model return $sourceData ? new Source($this->pdo, $sourceData) : null; } + /** + * Get total count of movies with optional filters + */ + public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int + { + $where = []; + $params = []; + + $sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id"; + + if (!empty($search)) { + $where[] = "(m.title LIKE :search OR m.overview LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_$i"; + $genreConditions[] = "m.genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($directors)) { + $directorConditions = []; + foreach ($directors as $i => $director) { + $param = ":director_$i"; + $directorConditions[] = "m.director LIKE $param"; + $params[$param] = "%$director%"; + } + $where[] = "(" . implode(' OR ', $directorConditions) . ")"; + } + + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return (int)$stmt->fetchColumn(); + } + + /** + * Get paginated movies with optional filters + */ + public function getPaginated( + \PDO $pdo, + int $page = 1, + int $perPage = 20, + string $search = '', + array $genres = [], + string $sort = 'title_asc' + ): array { + $offset = ($page - 1) * $perPage; + $where = []; + $params = []; + + if (!empty($search)) { + $where[] = "(title LIKE :search OR overview LIKE :search OR director LIKE :search OR writer LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre$i"; + $genreConditions[] = "genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + // Determine sort order + $orderBy = 'title ASC'; + switch ($sort) { + case 'title_desc': + $orderBy = 'title DESC'; + break; + case 'release_asc': + $orderBy = 'release_date ASC'; + break; + case 'release_desc': + $orderBy = 'release_date DESC'; + break; + case 'rating_desc': + $orderBy = 'rating DESC'; + break; + case 'rating_asc': + $orderBy = 'rating ASC'; + break; + } + + $sql = "SELECT * FROM {$this->table}"; + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + $sql .= " ORDER BY $orderBy LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + + // Bind parameters + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + $stmt->execute(); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all unique genres from movies + */ + public function getGenres(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT genre FROM {$this->table} WHERE genre IS NOT NULL AND genre != ''"); + $results = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + $genres = []; + foreach ($results as $genreList) { + $genreArray = array_map('trim', explode(',', $genreList)); + $genres = array_merge($genres, $genreArray); + } + + $genres = array_unique($genres); + sort($genres); + + return array_values(array_filter($genres)); + } + + /** + * Get all unique directors from movies + */ + public function getDirectors(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT director FROM {$this->table} WHERE director IS NOT NULL AND director != '' ORDER BY director"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + public function markAsWatched(): bool { $this->watched = true; @@ -108,7 +252,7 @@ class Movie extends Model $stmt->execute(['limit' => $limit]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } - +/* public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int { $sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id"; @@ -146,8 +290,8 @@ class Movie extends Model $stmt->execute(); return (int) $stmt->fetch()['count']; } - - public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array +*/ + public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = [], string $sort = 'title_asc'): array { $offset = ($page - 1) * $perPage; @@ -170,20 +314,47 @@ class Movie extends Model $params["genre_{$index}"] = $genre; } $whereClause = !empty($search) ? " AND" : " WHERE"; - $sql .= $whereClause . " m.genre IN (" . implode(',', $placeholders) . ")"; + $sql .= $whereClause . " ("; + foreach ($placeholders as $i => $placeholder) { + if ($i > 0) $sql .= " OR "; + $sql .= "m.genre LIKE $placeholder"; + } + $sql .= ")"; } if (!empty($directors)) { $placeholders = []; foreach ($directors as $index => $director) { $placeholders[] = ":director_{$index}"; - $params["director_{$index}"] = $director; + $params["director_{$index}"] = "%$director%"; } $whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE"; - $sql .= $whereClause . " m.director IN (" . implode(',', $placeholders) . ")"; + $sql .= $whereClause . " ("; + foreach ($placeholders as $i => $placeholder) { + if ($i > 0) $sql .= " OR "; + $sql .= "m.director LIKE $placeholder"; + } + $sql .= ")"; } - $sql .= " ORDER BY m.title ASC LIMIT :limit OFFSET :offset"; + // Add sorting + $sortOptions = [ + 'title_asc' => 'm.title ASC', + 'title_desc' => 'm.title DESC', + 'year_asc' => 'm.release_date ASC', + 'year_desc' => 'm.release_date DESC', + 'rating_asc' => 'm.rating ASC NULLS LAST', + 'rating_desc' => 'm.rating DESC NULLS LAST', + 'views_asc' => 'm.watch_count ASC', + 'views_desc' => 'm.watch_count DESC', + 'added_asc' => 'm.created_at ASC', + 'added_desc' => 'm.created_at DESC', + 'last_watched_asc' => 'm.last_watched_at ASC NULLS LAST', + 'last_watched_desc' => 'm.last_watched_at DESC NULLS LAST' + ]; + + $sortClause = $sortOptions[$sort] ?? 'm.title ASC'; + $sql .= " ORDER BY $sortClause LIMIT :limit OFFSET :offset"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); diff --git a/app/Models/TvShow.php b/app/Models/TvShow.php index 398cc54..53ae980 100644 --- a/app/Models/TvShow.php +++ b/app/Models/TvShow.php @@ -51,20 +51,6 @@ class TvShow extends Model ]); } - /** - * Update the cast field with actor names - */ - public function updateCastField(): bool - { - $actors = $this->actors(); - $actorNames = array_column($actors, 'name'); - $castString = implode(', ', $actorNames); - - return $this->update($this->id, [ - 'cast' => $castString - ]); - } - /** * Get TV show statistics */ @@ -81,44 +67,64 @@ class TvShow extends Model } /** - * Get total count with optional search + * Get total count with optional search and filters */ - public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $years = []): int - { + public static function getTotalCount( + \PDO $pdo, + string $search = '', + array $genres = [], + array $networks = [], + array $statuses = [] + ): int { $sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id"; $params = []; + $whereClauses = []; if (!empty($search)) { - $sql .= " WHERE t.title LIKE :search"; + $whereClauses[] = "(t.title LIKE :search OR t.overview LIKE :search)"; $params['search'] = "%{$search}%"; } if (!empty($genres)) { - $placeholders = []; - foreach ($genres as $index => $genre) { - $placeholders[] = ":genre_{$index}"; - $params["genre_{$index}"] = $genre; + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_{$i}"; + $genreConditions[] = "FIND_IN_SET({$param}, t.genre) > 0"; + $params["genre_{$i}"] = $genre; } - $whereClause = !empty($search) ? " AND" : " WHERE"; - $sql .= $whereClause . " t.genre IN (" . implode(',', $placeholders) . ")"; + $whereClauses[] = '(' . implode(' OR ', $genreConditions) . ')'; } - if (!empty($years)) { - $placeholders = []; - foreach ($years as $index => $year) { - $placeholders[] = ":year_{$index}"; - $params["year_{$index}"] = $year; + if (!empty($networks)) { + $networkConditions = []; + foreach ($networks as $i => $network) { + $param = ":network_{$i}"; + $networkConditions[] = "t.networks LIKE {$param}"; + $params["network_{$i}"] = "%\"name\":\"{$network}\"%"; } - $whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE"; - $sql .= $whereClause . " YEAR(first_air_date) IN (" . implode(',', $placeholders) . ")"; + $whereClauses[] = '(' . implode(' OR ', $networkConditions) . ')'; + } + + if (!empty($statuses)) { + $statusConditions = []; + foreach ($statuses as $i => $status) { + $param = ":status_{$i}"; + $statusConditions[] = "t.status = {$param}"; + $params["status_{$i}"] = $status; + } + $whereClauses[] = '(' . implode(' OR ', $statusConditions) . ')'; + } + + if (!empty($whereClauses)) { + $sql .= ' WHERE ' . implode(' AND ', $whereClauses); } $stmt = $pdo->prepare($sql); foreach ($params as $key => $value) { - $stmt->bindValue(":{$key}", $value); + $stmt->bindValue($key, $value); } $stmt->execute(); - return (int) $stmt->fetch()['count']; + return (int) $stmt->fetch(\PDO::FETCH_COLUMN); } /** @@ -206,6 +212,135 @@ class TvShow extends Model return $sourceData ? new Source($this->pdo, $sourceData) : null; } + /** + * Get paginated TV shows with filters + */ + public static function getPaginated( + \PDO $pdo, + int $page, + int $perPage, + string $search = '', + array $genres = [], + array $networks = [], + array $statuses = [], + string $sort = 'title_asc' + ): array { + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT t.*, s.display_name as source_name + FROM tv_shows t + JOIN sources s ON t.source_id = s.id + "; + $params = []; + $whereClauses = []; + + if (!empty($search)) { + $whereClauses[] = "(t.title LIKE :search OR t.overview LIKE :search)"; + $params['search'] = "%{$search}%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_{$i}"; + $genreConditions[] = "FIND_IN_SET({$param}, t.genre) > 0"; + $params["genre_{$i}"] = $genre; + } + $whereClauses[] = '(' . implode(' OR ', $genreConditions) . ')'; + } + + if (!empty($networks)) { + $networkConditions = []; + foreach ($networks as $i => $network) { + $param = ":network_{$i}"; + $networkConditions[] = "t.networks LIKE {$param}"; + $params["network_{$i}"] = "%\"name\":\"{$network}\"%"; + } + $whereClauses[] = '(' . implode(' OR ', $networkConditions) . ')'; + } + + if (!empty($statuses)) { + $statusConditions = []; + foreach ($statuses as $i => $status) { + $param = ":status_{$i}"; + $statusConditions[] = "t.status = {$param}"; + $params["status_{$i}"] = $status; + } + $whereClauses[] = '(' . implode(' OR ', $statusConditions) . ')'; + } + + if (!empty($whereClauses)) { + $sql .= ' WHERE ' . implode(' AND ', $whereClauses); + } + + // Add sorting + $sortMap = [ + 'title_asc' => 't.title ASC', + 'title_desc' => 't.title DESC', + 'rating_desc' => 't.vote_average DESC NULLS LAST', + 'rating_asc' => 't.vote_average ASC NULLS LAST', + 'newest' => 't.first_air_date DESC NULLS LAST', + 'oldest' => 't.first_air_date ASC NULLS LAST', + ]; + + $sortClause = $sortMap[$sort] ?? 't.title ASC'; + $sql .= " ORDER BY {$sortClause} LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all available genres from TV shows + */ + public static function getGenres(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT DISTINCT TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(t.genre, ',', n.n), ',', -1)) as genre + FROM tv_shows t + JOIN ( + SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL + SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 + ) n + WHERE n.n <= LENGTH(t.genre) - LENGTH(REPLACE(t.genre, ',', '')) + 1 + AND t.genre IS NOT NULL AND t.genre != '' + ORDER BY genre + "); + + $genres = $stmt->fetchAll(\PDO::FETCH_COLUMN); + return array_values(array_filter(array_unique($genres))); + } + + /** + * Get all available networks from TV shows + */ + public static function getNetworks(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT networks FROM tv_shows WHERE networks IS NOT NULL AND networks != ''"); + $networks = []; + + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $showNetworks = json_decode($row['networks'], true) ?: []; + foreach ($showNetworks as $network) { + if (isset($network['name'])) { + $networks[$network['name']] = $network['name']; + } + } + } + + sort($networks); + return array_values($networks); + } + public function getSeasonsWithEpisodes(): array { // Get all episodes for this TV show, grouped by season diff --git a/app/Services/SteamGridDbService.php b/app/Services/SteamGridDbService.php new file mode 100644 index 0000000..94c7d6f --- /dev/null +++ b/app/Services/SteamGridDbService.php @@ -0,0 +1,132 @@ +apiKey = $apiKey ?? $_ENV['STEAMGRIDDB_API_KEY'] ?? null; + $this->client = new Client([ + 'base_uri' => self::BASE_URI, + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'Accept' => 'application/json', + ], + 'http_errors' => false, + ]); + } + + /** + * Search for games by name + */ + public function searchGames(string $query): array + { + try { + $response = $this->client->get('search/autocomplete/' . urlencode($query)); + $data = json_decode($response->getBody()->getContents(), true); + return $data['data'] ?? []; + } catch (GuzzleException $e) { + return []; + } + } + + /** + * Get game by ID + */ + public function getGame(int $gameId): ?array + { + try { + $response = $this->client->get('games/id/' . $gameId); + $data = json_decode($response->getBody()->getContents(), true); + return $data['data'] ?? null; + } catch (GuzzleException $e) { + return null; + } + } + + /** + * Get game grids (covers) + */ + public function getGrids(int $gameId, array $options = []): array + { + return $this->getMedia('grids/game/' . $gameId, $options); + } + + /** + * Get game heroes (backgrounds) + */ + public function getHeroes(int $gameId, array $options = []): array + { + return $this->getMedia('heroes/game/' . $gameId, $options); + } + + /** + * Get game icons + */ + public function getIcons(int $gameId, array $options = []): array + { + return $this->getMedia('icons/game/' . $gameId, $options); + } + + /** + * Get game logos + */ + public function getLogos(int $gameId, array $options = []): array + { + return $this->getMedia('logos/game/' . $gameId, $options); + } + + /** + * Download a media file + */ + public function downloadMedia(string $url): ?string + { + try { + $response = $this->client->get($url, ['stream' => true]); + + if ($response->getStatusCode() !== 200) { + return null; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'sgdb_'); + file_put_contents($tempFile, $response->getBody()); + + return $tempFile; + } catch (GuzzleException $e) { + return null; + } + } + + private function getMedia(string $endpoint, array $options = []): array + { + $query = []; + if (!empty($options['styles'])) { + $query['styles'] = is_array($options['styles']) ? implode(',', $options['styles']) : $options['styles']; + } + if (!empty($options['dimensions'])) { + $query['dimensions'] = is_array($options['dimensions']) ? implode(',', $options['dimensions']) : $options['dimensions']; + } + if (!empty($options['mimes'])) { + $query['mimes'] = is_array($options['mimes']) ? implode(',', $options['mimes']) : $options['mimes']; + } + if (!empty($options['types'])) { + $query['types'] = is_array($options['types']) ? implode(',', $options['types']) : $options['types']; + } + + try { + $response = $this->client->get($endpoint, ['query' => $query]); + $data = json_decode($response->getBody()->getContents(), true); + return $data['data'] ?? []; + } catch (GuzzleException $e) { + return []; + } + } +} diff --git a/composer.json b/composer.json index 7836965..e561a6f 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "slim/psr7": "^1.6", "slim/twig-view": "^3.3", "php-di/php-di": "^7.0", - "illuminate/database": "^10.0" + "illuminate/database": "^10.0", + "zircote/swagger-php": "^5.5", + "php-middleware/php-debug-bar": "^1.0" }, "autoload": { "psr-4": { @@ -23,5 +25,8 @@ "config": { "optimize-autoloader": true }, - "minimum-stability": "stable" + "minimum-stability": "stable", + "require-dev": { + "maximebf/debugbar": "^1.23" + } } diff --git a/config/database.php b/config/database.php index e4c3a01..edc14b9 100644 --- a/config/database.php +++ b/config/database.php @@ -1,12 +1,12 @@ env('DB_CONNECTION', 'sqlite'), - 'host' => env('DB_HOST', '127.0.0.1'), + 'driver' => env('DB_CONNECTION', 'mysql'), + 'host' => env('DB_HOST', '192.168.1.102'), 'port' => env('DB_PORT', 3306), - 'database' => env('DB_DATABASE', __DIR__ . '/../database/database.sqlite'), - 'username' => env('DB_USERNAME', ''), - 'password' => env('DB_PASSWORD', ''), + 'database' => env('DB_DATABASE', 'phpmedialib'), + 'username' => env('DB_USERNAME', 'phpmedialib'), + 'password' => env('DB_PASSWORD', 'phpmedialib'), 'charset' => env('DB_CHARSET', 'utf8mb4'), 'prefix' => env('DB_PREFIX', ''), 'schema' => env('DB_SCHEMA', 'public'), diff --git a/database/Migration.php b/database/Migration.php new file mode 100644 index 0000000..3d54e32 --- /dev/null +++ b/database/Migration.php @@ -0,0 +1,16 @@ +pdo = $pdo; + } + + abstract public function up(); + abstract public function down(); +} diff --git a/database/migrations/2025_10_30_000000_create_adult_video_actor_tables.php b/database/migrations/2025_10_30_000000_create_adult_video_actor_tables.php new file mode 100644 index 0000000..e0384c1 --- /dev/null +++ b/database/migrations/2025_10_30_000000_create_adult_video_actor_tables.php @@ -0,0 +1,30 @@ +pdo->exec(" + CREATE TABLE IF NOT EXISTS adult_video_actor ( + id INT AUTO_INCREMENT PRIMARY KEY, + adult_video_id INT NOT NULL, + actor_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (adult_video_id) REFERENCES adult_videos(id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES actors(id) ON DELETE CASCADE, + UNIQUE KEY unique_adult_video_actor (adult_video_id, actor_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + public function down() + { + $this->pdo->exec("DROP TABLE IF EXISTS adult_video_actor"); + } +} diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..b723179 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,118 @@ + .items, + .item { + flex-flow: row wrap; + } + .items .item, + .item .item { + margin: 20px; + width: 120px; + height: 180px; + overflow: hidden; + box-shadow: 0 5px 10px rgba(0,0,0,0.8); + transform-origin: center top; + transform-style: preserve-3d; + transform: translateZ(0); + transition: 0.3s; + } + .items .item img, + .item .item img { + width: 100%; + min-height: 100%; + } + .items .item figcaption, + .item .item figcaption { + bottom: 0; + left: 0; + right: 0; + padding: 20px; + padding-bottom: 10px; + font-size: 20px; + background: none; + color: #fff; + transform: translateY(100%); + transition: 0.3s; + } + .items .item:after, + .item .item:after { + content: ''; + z-index: 10; + width: 200%; + height: 100%; + top: -90%; + left: -20px; + opacity: 0.1; + transform: rotate(45deg); + background: linear-gradient(to top, transparent, #fff 15%, rgba(255,255,255,0.5)); + transition: 0.3s; + } + .items .item:hover, + .item .item:hover, + .items .item:focus, + .item .item:focus, + .items .item:active, + .item .item:active { + box-shadow: 0 8px 16px 3px rgba(0,0,0,0.6); + transform: translateY(-3px) scale(1.05) rotateX(15deg); + } + .items .item:hover figcaption, + .item .item:hover figcaption, + .items .item:focus figcaption, + .item .item:focus figcaption, + .items .item:active figcaption, + .item .item:active figcaption { + transform: none; + } + .items .item:hover:after, + .item .item:hover:after, + .items .item:focus:after, + .item .item:focus:after, + .items .item:active:after, + .item .item:active:after { + transform: rotate(25deg); + top: -40%; + opacity: 0.15; + } + .item .article { + overflow: hidden; + width: 350px; + height: 235px; + margin: 20px; + } + .item .article img { + width: 100%; + min-height: 100%; + transition: 0.2s; + } + .item .article figcaption { + font-size: 14px; + text-shadow: 0 1px 0 rgba(51,51,51,0.3); + color: #fff; + left: 0; + right: 0; + top: 0; + bottom: 0; + padding: 40px; + box-shadow: 0 0 2px rgba(0,0,0,0.2); + background: rgba(6,18,53,0.6); + opacity: 0; + transform: scale(1.15); + transition: 0.2s; + } + .item .article figcaption h3 { + color: #3792e3; + font-size: 16px; + margin-bottom: 0; + font-weight: bold; + } + .item .article:hover img, + .item .article:focus img, + .item .article:active img { + filter: blur(3px); + transform: scale(0.97); + } + .item .article:hover figcaption, + .item .article:focus figcaption, + .item .article:active figcaption { + opacity: 1; + transform: none; + } \ No newline at end of file diff --git a/public/index.php b/public/index.php index 77b537d..dc764c6 100644 --- a/public/index.php +++ b/public/index.php @@ -263,13 +263,13 @@ $container->set(\App\Controllers\SettingsController::class, function ($c) { return new \App\Controllers\SettingsController($c->get(PDO::class), $c->get('view')); }); -// Register PlayniteImportController -$container->set(\App\Controllers\PlayniteImportController::class, function ($c) { - return new \App\Controllers\PlayniteImportController( - $c->get(PDO::class), - $c->get('view'), - $c->get(\App\Services\AuthService::class) - ); +// Register API controllers +$container->set(\App\Controllers\Api\PlayniteController::class, function ($c) { + return new \App\Controllers\Api\PlayniteController($c->get(PDO::class)); +}); + +$container->set(\App\Controllers\Api\AuthController::class, function ($c) { + return new \App\Controllers\Api\AuthController($c->get(\App\Services\AuthService::class)); }); // Register PlayniteImportService @@ -298,6 +298,69 @@ $app = AppFactory::create(); $twig = $container->get('view'); $app->add(TwigMiddleware::create($app, $twig)); +// PHP DebugBar Setup (only in development) +if ($_ENV['APP_DEBUG'] === 'true') { + $debugbar = new \DebugBar\StandardDebugBar(); + + // Set up the debug bar renderer with the correct base URL + $baseUrl = rtrim($app->getBasePath(), '/'); + $debugbarRenderer = $debugbar->getJavascriptRenderer($baseUrl . '/phpdebugbar'); + + // Add DebugBar to Twig globals + $twig->getEnvironment()->addGlobal('debugbarRenderer', $debugbarRenderer); + + // Add route to serve DebugBar assets + $app->get('/phpdebugbar/{path:.*}', function ($request, $response, $args) use ($debugbar) { + $debugbarRenderer = $debugbar->getJavascriptRenderer(); + $path = $args['path']; + + // Serve CSS files + if (preg_match('/\.css$/', $path)) { + $content = file_get_contents($debugbarRenderer->getBasePath() . '/' . $path); + $response->getBody()->write($content); + return $response->withHeader('Content-Type', 'text/css'); + } + + // Serve JS files + if (preg_match('/\.js$/', $path)) { + $content = file_get_contents($debugbarRenderer->getBasePath() . '/' . $path); + $response->getBody()->write($content); + return $response->withHeader('Content-Type', 'application/javascript'); + } + + // Serve other assets (fonts, etc.) + $content = @file_get_contents($debugbarRenderer->getBasePath() . '/' . $path); + if ($content !== false) { + $response->getBody()->write($content); + return $response; + } + + return $response->withStatus(404); + }); + + // Add middleware to collect data + $app->add(function ($request, $handler) use ($debugbar) { + // Start timing the request + $debugbar['time']->startMeasure('app', 'Application'); + + try { + $response = $handler->handle($request); + + // Stop timing if it was started + if ($debugbar['time']->hasStartedMeasure('app')) { + $debugbar['time']->stopMeasure('app'); + } + + return $response; + } catch (\Exception $e) { + // Make sure to stop timing even if an exception occurs + if ($debugbar['time']->hasStartedMeasure('app')) { + $debugbar['time']->stopMeasure('app'); + } + throw $e; + } + }); +} // Add Error Middleware $errorMiddleware = $app->addErrorMiddleware( $_ENV['APP_DEBUG'] === 'true', @@ -307,5 +370,6 @@ $errorMiddleware = $app->addErrorMiddleware( // Register routes require __DIR__ . '/../routes/web.php'; +require __DIR__ . '/../routes/api.php'; $app->run(); diff --git a/public/js/adult-video-actors.js b/public/js/adult-video-actors.js new file mode 100644 index 0000000..27caf11 --- /dev/null +++ b/public/js/adult-video-actors.js @@ -0,0 +1,125 @@ +document.addEventListener('DOMContentLoaded', function() { + const videoId = document.getElementById('video-id').value; + const actorSearch = document.getElementById('actor-search'); + const actorResults = document.getElementById('actor-results'); + const actorsList = document.getElementById('actors-list'); + + // Debounce search + let searchTimeout; + actorSearch.addEventListener('input', function(e) { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + actorResults.innerHTML = ''; + actorResults.classList.add('d-none'); + return; + } + + searchTimeout = setTimeout(() => { + searchActors(query); + }, 300); + }); + + // Search for actors + function searchActors(query) { + fetch(`/admin/adult-videos/search-actors?q=${encodeURIComponent(query)}`) + .then(response => response.json()) + .then(data => { + actorResults.innerHTML = ''; + + if (data.data && data.data.length > 0) { + data.data.forEach(actor => { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action'; + item.textContent = actor.name; + item.addEventListener('click', () => addActorToVideo(actor.id, actor.name)); + actorResults.appendChild(item); + }); + actorResults.classList.remove('d-none'); + } else { + const noResults = document.createElement('div'); + noResults.className = 'list-group-item'; + noResults.textContent = 'No actors found'; + actorResults.appendChild(noResults); + actorResults.classList.remove('d-none'); + } + }); + } + + // Add actor to video + function addActorToVideo(actorId, actorName) { + fetch(`/admin/adult-videos/${videoId}/actors`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ actor_id: actorId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + addActorToList(actorId, actorName); + actorSearch.value = ''; + actorResults.innerHTML = ''; + actorResults.classList.add('d-none'); + } else { + alert('Failed to add actor: ' + (data.error || 'Unknown error')); + } + }); + } + + // Add actor to the UI list + function addActorToList(actorId, actorName) { + const item = document.createElement('div'); + item.className = 'd-flex justify-content-between align-items-center mb-2'; + item.dataset.actorId = actorId; + item.innerHTML = ` + ${actorName} + + `; + actorsList.appendChild(item); + + // Add event listener to the remove button + item.querySelector('.remove-actor').addEventListener('click', () => removeActor(actorId, item)); + } + + // Remove actor from video + function removeActor(actorId, element) { + if (confirm('Are you sure you want to remove this actor?')) { + fetch(`/admin/adult-videos/${videoId}/actors/${actorId}`, { + method: 'DELETE', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + element.remove(); + } else { + alert('Failed to remove actor: ' + (data.error || 'Unknown error')); + } + }); + } + } + + // Load existing actors + function loadActors() { + fetch(`/admin/adult/${videoId}/actors`) + .then(response => response.json()) + .then(data => { + if (data.data && data.data.length > 0) { + data.data.forEach(actor => { + addActorToList(actor.id, actor.name); + }); + } + }); + } + + // Initialize + loadActors(); +}); diff --git a/resources/views/actor/index.twig b/resources/views/actor/index.twig index 7037d43..ad22f7b 100644 --- a/resources/views/actor/index.twig +++ b/resources/views/actor/index.twig @@ -4,17 +4,28 @@
-

Adult Performers

+

Performers

{{ actors|length }} performer{{ actors|length != 1 ? 's' : '' }}

- +
{% if actors %}
{% for actor in actors %} -
-
-
+ +
+ + +
{{ actor.name }}
+
+
+ + + + {% endfor %}
+
{% else %}
diff --git a/resources/views/admin/adult/edit.twig b/resources/views/admin/adult/edit.twig new file mode 100644 index 0000000..d7550c3 --- /dev/null +++ b/resources/views/admin/adult/edit.twig @@ -0,0 +1,233 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}{{ video ? 'Edit' : 'Add New' }} Adult Video - Admin Panel - MediaLib{% endblock %} + +{% block content %} +
+
+

{{ video ? 'Edit' : 'Add New' }} Adult Video

+

{{ video ? 'Update the video details' : 'Add a new video to your library' }}

+
+ + Back to List + +
+ +
+
+ {% if flash.getMessage('error') %} +
+ {{ flash.getMessage('error') | first }} +
+ {% endif %} + +
+
+
+
+
+
+ {% if video and video.poster_url %} + Poster + {% else %} +
+ +
+ {% endif %} +
+
+ + +
+ {% if video and video.poster_url %} +
+ + +
+ {% endif %} +
+
+
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+ + +
+ + +
+
+
Actors
+
+
+ + + + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ +
+
+
+
+ +
+
+ + + Cancel + +
+ {% if video %} + + {% endif %} +
+
+
+
+
+
+ + {% if video %} + + + {% endif %} +{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/resources/views/admin/adult/index.twig b/resources/views/admin/adult/index.twig new file mode 100644 index 0000000..c31c5fe --- /dev/null +++ b/resources/views/admin/adult/index.twig @@ -0,0 +1,212 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}Manage Adult Videos - Admin Panel - MediaLib{% endblock %} + +{% block content %} +
+
+

Manage Adult Videos

+

View and manage your adult video library

+
+ + Add New Video + +
+ + {# Search and Filters #} +
+
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + + + +
+
+
+
+
+
+ +
+
+ {% if flash.getMessage('success') %} +
+ {{ flash.getMessage('success') | first }} +
+ {% endif %} + +
+ + + + + + + + + + + + + + {% for video in videos %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ThumbnailTitleDirectorGenreRatingRelease DateActions
+ {% if video.poster_url %} + {{ video.title }} + {% else %} +
+ +
+ {% endif %} +
+
{{ video.title }}
+ {{ video.runtime_minutes ? video.runtime_minutes ~ ' min' : 'N/A' }} +
{{ video.director ?? 'N/A' }}{{ video.genre ?? 'N/A' }} + {% if video.rating %} + {{ video.rating|number_format(1) }}/10 + {% else %} + N/A + {% endif %} + {{ video.release_date ? video.release_date|date('Y-m-d') : 'N/A' }} +
+ + + +
+ + +
+
+
+
No videos found. Add your first video
+
+
+ + {% if pagination.total > 1 %} +
+
+ Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total_items }} videos +
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/resources/views/admin/games/edit.twig b/resources/views/admin/games/edit.twig new file mode 100644 index 0000000..204bc09 --- /dev/null +++ b/resources/views/admin/games/edit.twig @@ -0,0 +1,737 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}{{ title }} - Admin Panel - MediaLib{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

{{ game ? 'Edit game details' : 'Add a new game to your library' }}

+
+ + Back to Games + +
+ +
+
+
+
+ {% if flash.getMessage('success') %} +
+ {{ flash.getMessage('success') | first }} +
+ {% endif %} + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
Example: Action, Adventure, RPG
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if game %} + + {% endif %} +
+
+
+
+
+ +
+
+
+
Cover Preview
+
+
+
+ {% if game and game.cover_image %} + Cover + {% else %} +
No cover available
+ {% endif %} +
+
+
+ + + {% if game %} +
+
+
SteamGridDB Media
+
+
+
+ +
+ + +
+
+
+ +
+
Available Media
+
+ +
+
+
+ + +
+
+
+
+ {% endif %} + + +
+
+
Metadata
+
+
+
+ Created: + {{ game ? game.created_at|date('Y-m-d H:i') : 'New' }} +
+
+ Last Updated: + {{ game ? game.updated_at|date('Y-m-d H:i') : 'N/A' }} +
+
+
+
+
+ + + {% if game %} + + {% endif %} +{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/resources/views/admin/games/index.twig b/resources/views/admin/games/index.twig new file mode 100644 index 0000000..e685f1a --- /dev/null +++ b/resources/views/admin/games/index.twig @@ -0,0 +1,218 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}Manage Games - Admin Panel - MediaLib{% endblock %} + +{% block content %} +
+
+

Manage Games

+

View and manage your game library

+
+ + Add New Game + +
+ +
+
+ {% if flash.getMessage('success') %} +
+ {{ flash.getMessage('success') | first }} +
+ {% endif %} + + +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + + + +
+
+
+
+ + +
+
+ Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total_items }} games +
+
+ + +
+
+ +
+ + + + + + + + + + + + + {% for game in games %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
CoverTitlePlatformRelease DateRatingActions
+ {% if game.cover_url %} + {{ game.title }} + {% else %} +
+ +
+ {% endif %} +
+
{{ game.title }}
+ {{ game.developer ? game.developer : 'N/A' }} +
{{ game.platform ? game.platform : 'N/A' }}{{ game.release_date ? game.release_date|date('Y') : 'N/A' }} + {% if game.rating %} + {{ game.rating|number_format(1) }}/5 + {% else %} + N/A + {% endif %} + +
+ + + +
+ + +
+
+
+
No games found. Add your first game
+
+
+ + + {% if pagination.total > 1 %} + + {% endif %} +
+
+{% endblock %} diff --git a/resources/views/admin/layout.twig b/resources/views/admin/layout.twig index 8144fb8..59d08f7 100644 --- a/resources/views/admin/layout.twig +++ b/resources/views/admin/layout.twig @@ -215,25 +215,25 @@