pdo = $pdo; } public function show(Request $request, Response $response, $args) { $actorId = $args['id']; // Get actor details with counts from all media types (including episode actors) $stmt = $this->pdo->prepare(" SELECT a.*, COUNT(DISTINCT am.movie_id) as movie_count, COUNT(DISTINCT CASE WHEN ats.tv_show_id IS NOT NULL THEN ats.tv_show_id WHEN ate.tv_episode_id IS NOT NULL THEN te.tv_show_id END) as tv_show_count, COUNT(DISTINCT aav.adult_video_id) as adult_video_count, (COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT CASE WHEN ats.tv_show_id IS NOT NULL THEN ats.tv_show_id WHEN ate.tv_episode_id IS NOT NULL THEN te.tv_show_id END) + COUNT(DISTINCT aav.adult_video_id)) as total_media_count FROM actors a LEFT JOIN actor_movie am ON a.id = am.actor_id LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id LEFT JOIN actor_tv_episode ate ON a.id = ate.actor_id LEFT JOIN tv_episodes te ON ate.tv_episode_id = te.id LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id WHERE a.id = :actor_id GROUP BY a.id "); $stmt->execute(['actor_id' => $actorId]); $actor = $stmt->fetch(PDO::FETCH_ASSOC); if (!$actor) { return $response->withStatus(404)->withHeader('Content-Type', 'text/html'); } // Get actor's adult videos (scenes) $stmt = $this->pdo->prepare(" SELECT av.*, s.display_name as source_name FROM adult_videos av JOIN sources s ON av.source_id = s.id JOIN actor_adult_video aav ON av.id = aav.adult_video_id WHERE aav.actor_id = :actor_id ORDER BY av.release_date DESC, av.title ASC "); $stmt->execute(['actor_id' => $actorId]); $scenes = $stmt->fetchAll(PDO::FETCH_ASSOC); // Get actor's movies $stmt = $this->pdo->prepare(" SELECT m.*, s.display_name as source_name FROM movies m JOIN sources s ON m.source_id = s.id JOIN actor_movie am ON m.id = am.movie_id WHERE am.actor_id = :actor_id ORDER BY m.release_date DESC, m.title ASC "); $stmt->execute(['actor_id' => $actorId]); $movies = $stmt->fetchAll(PDO::FETCH_ASSOC); // Get actor's TV shows (from main cast and episodes) $stmt = $this->pdo->prepare(" SELECT DISTINCT ts.*, s.display_name as source_name FROM tv_shows ts JOIN sources s ON ts.source_id = s.id LEFT JOIN actor_tv_show ats ON ts.id = ats.tv_show_id AND ats.actor_id = :actor_id LEFT JOIN tv_episodes te ON ts.id = te.tv_show_id LEFT JOIN actor_tv_episode ate ON te.id = ate.tv_episode_id AND ate.actor_id = :actor_id2 WHERE ats.actor_id = :actor_id4 OR ate.actor_id = :actor_id3 ORDER BY ts.first_air_date DESC, ts.title ASC "); $stmt->execute(['actor_id' => $actorId,'actor_id2' => $actorId,'actor_id3' => $actorId, 'actor_id4' => $actorId]); $tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($scenes as &$scene) { if (!empty($scene['metadata'])) { $metadata = json_decode($scene['metadata'], true); // Use local cover path if available, otherwise fall back to original URL if (!empty($metadata['local_cover_path'])) { $scene['poster_url'] = $metadata['local_cover_path']; } elseif (!empty($metadata['cover_url'])) { $scene['poster_url'] = $metadata['cover_url']; } // Add actors data if available if (!empty($metadata['actors'])) { $scene['actors'] = $metadata['actors']; } } } return $this->view->render($response, 'actor/show.twig', [ 'title' => $actor['name'], 'actor' => $actor, 'scenes' => $scenes, 'movies' => $movies, 'tv_shows' => $tvShows ]); } public function edit(Request $request, Response $response, $args) { $actorId = $args['id']; // Get actor details $stmt = $this->pdo->prepare("SELECT * FROM actors WHERE id = :id"); $stmt->execute(['id' => $actorId]); $actor = $stmt->fetch(PDO::FETCH_ASSOC); if (!$actor) { return $response->withStatus(404)->withHeader('Content-Type', 'text/html'); } // Decode metadata for form population $metadata = json_decode($actor['metadata'] ?? '{}', true); // Handle POST request (form submission) if ($request->getMethod() === 'POST') { $data = $request->getParsedBody(); $uploadedFiles = $request->getUploadedFiles(); // Validate required fields $name = trim($data['name'] ?? ''); if (empty($name)) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Name is required' ]); } // Handle image upload/download $thumbnailPath = $actor['thumbnail_path']; // Keep existing by default $imageSource = $data['image_source'] ?? 'upload'; if ($imageSource === 'upload') { // Handle file upload if (!empty($uploadedFiles['thumbnail']) && $uploadedFiles['thumbnail']->getError() === UPLOAD_ERR_OK) { $uploadedFile = $uploadedFiles['thumbnail']; // Validate file type $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!in_array($uploadedFile->getClientMediaType(), $allowedTypes)) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Invalid image type. Only JPEG, PNG, GIF, and WebP are allowed.' ]); } // Generate filename and move file $extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION); $filename = 'actor_' . $actorId . '_' . time() . '.' . $extension; $uploadPath = __DIR__ . '/../../public/images/actors/' . $filename; // Create directory if it doesn't exist $uploadDir = dirname($uploadPath); if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $uploadedFile->moveTo($uploadPath); $thumbnailPath = '/images/actors/' . $filename; } } elseif ($imageSource === 'url') { // Handle URL download $imageUrl = trim($data['thumbnail_url'] ?? ''); if (!empty($imageUrl)) { // Validate URL if (!filter_var($imageUrl, FILTER_VALIDATE_URL)) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Invalid image URL provided.' ]); } try { // Download image from URL $imageData = file_get_contents($imageUrl); if ($imageData === false) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Failed to download image from the provided URL.' ]); } // Get image info to validate type and determine extension $imageInfo = getimagesizefromstring($imageData); if (!$imageInfo) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'The URL does not point to a valid image.' ]); } // Validate MIME type $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!in_array($imageInfo['mime'], $allowedTypes)) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Invalid image type. Only JPEG, PNG, GIF, and WebP are allowed.' ]); } // Determine extension from MIME type $extension = ''; switch ($imageInfo['mime']) { case 'image/jpeg': $extension = 'jpg'; break; case 'image/png': $extension = 'png'; break; case 'image/gif': $extension = 'gif'; break; case 'image/webp': $extension = 'webp'; break; } // Generate filename and save file $filename = 'actor_' . $actorId . '_' . time() . '.' . $extension; $uploadPath = __DIR__ . '/../../public/images/actors/' . $filename; // Create directory if it doesn't exist $uploadDir = dirname($uploadPath); if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } // Save the downloaded image if (file_put_contents($uploadPath, $imageData) === false) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Failed to save the downloaded image.' ]); } $thumbnailPath = '/images/actors/' . $filename; } catch (Exception $e) { return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata, 'error' => 'Error downloading image: ' . $e->getMessage() ]); } } } // Prepare metadata $actorMetadata = [ 'biography' => trim($data['biography'] ?? ''), 'birth_date' => trim($data['birth_date'] ?? ''), 'death_date' => trim($data['death_date'] ?? ''), 'birth_place' => trim($data['birth_place'] ?? ''), 'nationality' => trim($data['nationality'] ?? ''), 'gender' => trim($data['gender'] ?? ''), 'ethnicity' => trim($data['ethnicity'] ?? ''), 'country' => trim($data['nationality'] ?? ''), // Map nationality to country for Stash compatibility 'height' => trim($data['height'] ?? ''), 'measurements' => trim($data['measurements'] ?? ''), 'cup_size' => trim($data['cup_size'] ?? ''), 'piercings' => trim($data['piercings'] ?? ''), 'tattoos' => trim($data['tattoos'] ?? ''), 'hair_color' => trim($data['hair_color'] ?? ''), 'eye_color' => trim($data['eye_color'] ?? ''), 'weight' => trim($data['weight'] ?? ''), 'fake_tits' => trim($data['fake_tits'] ?? ''), 'penis_length' => trim($data['penis_length'] ?? ''), 'circumcised' => trim($data['circumcised'] ?? ''), 'career_length' => trim($data['career_length'] ?? ''), 'aliases' => array_filter(array_map('trim', explode(',', $data['aliases'] ?? ''))), 'favorite' => isset($data['favorite']) ? true : false, 'ignore_auto_tag' => isset($data['ignore_auto_tag']) ? true : false, 'scene_count' => (int)($data['scene_count'] ?? 0), 'details' => trim($data['details'] ?? ''), 'social_media' => [ 'twitter' => trim($data['twitter'] ?? ''), 'instagram' => trim($data['instagram'] ?? ''), 'onlyfans' => trim($data['onlyfans'] ?? ''), 'website' => trim($data['website'] ?? '') ], 'adult_specific' => [ 'debut_year' => trim($data['debut_year'] ?? ''), 'retirement_year' => trim($data['retirement_year'] ?? ''), 'active' => isset($data['active']) ? true : false, 'genres' => array_filter(array_map('trim', explode(',', $data['adult_genres'] ?? ''))), 'specialties' => array_filter(array_map('trim', explode(',', $data['specialties'] ?? ''))) ] ]; // Update actor $stmt = $this->pdo->prepare(" UPDATE actors SET name = :name, thumbnail_path = :thumbnail_path, metadata = :metadata, updated_at = NOW() WHERE id = :id "); $stmt->execute([ 'id' => $actorId, 'name' => $name, 'thumbnail_path' => $thumbnailPath, 'metadata' => json_encode($actorMetadata) ]); // Redirect back to actor show page return $response->withHeader('Location', '/media/actors/' . $actorId)->withStatus(302); } // GET request - show edit form return $this->view->render($response, 'actor/edit.twig', [ 'title' => 'Edit Actor', 'actor' => $actor, 'metadata' => $metadata ]); } public function fetchStashData(Request $request, Response $response, $args) { ini_set('memory_limit', '2G'); try { // Parse JSON body for API requests $rawBody = $request->getBody()->getContents(); $data = json_decode($rawBody, true); if (json_last_error() !== JSON_ERROR_NONE) { return $this->jsonResponse($response->withStatus(400), [ 'error' => 'Invalid JSON in request body' ]); } $query = trim($data['query'] ?? ''); if (empty($query)) { return $this->jsonResponse($response->withStatus(400), [ 'error' => 'Missing required parameter: query' ]); } // Get Stash configuration from database $stmt = $this->pdo->prepare('SELECT * FROM sources WHERE name = ?'); $stmt->execute(['stash']); $stashSource = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$stashSource) { return $this->jsonResponse($response->withStatus(500), [ 'error' => 'Stash source not configured in database' ]); } $stashUrl = trim($stashSource['api_url'] ?? ''); $stashApiKey = trim($stashSource['api_key'] ?? ''); if (empty($stashUrl)) { return $this->jsonResponse($response->withStatus(500), [ 'error' => 'Stash API URL not configured' ]); } // Create HTTP client for Stash API $client = new \GuzzleHttp\Client([ 'timeout' => 30, 'verify' => false, 'headers' => [ 'User-Agent' => 'MediaCollector/1.0', 'ApiKey' => $stashApiKey, 'Content-Type' => 'application/json' ] ]); // Build GraphQL query to search for performers $graphqlQuery = ' query FindPerformers($filter: FindFilterType) { findPerformers(filter: $filter) { performers { id name disambiguation url gender birthdate ethnicity country eye_color height_cm measurements fake_tits penis_length circumcised career_length tattoos piercings alias_list favorite ignore_auto_tag created_at updated_at details death_date hair_color weight image_path scene_count } count } } '; $variables = [ 'filter' => [ 'q' => $query, 'per_page' => 10, // Limit results 'sort' => 'name', 'direction' => 'ASC' ] ]; // Make the API call $apiResponse = $client->post(rtrim($stashUrl, '/') . '/graphql', [ 'json' => [ 'query' => $graphqlQuery, 'variables' => $variables ] ]); $result = json_decode($apiResponse->getBody(), true); if (!isset($result['data']['findPerformers']['performers'])) { return $this->jsonResponse($response, [ 'performers' => [], 'message' => 'No performers found' ]); } $performers = $result['data']['findPerformers']['performers']; return $this->jsonResponse($response, [ 'performers' => $performers, 'count' => count($performers), 'stash_url' => $stashUrl ]); } catch (\GuzzleHttp\Exception\RequestException $e) { $errorMessage = 'Failed to connect to Stash server'; if ($e->hasResponse()) { $statusCode = $e->getResponse()->getStatusCode(); $errorMessage .= ": HTTP {$statusCode}"; } return $this->jsonResponse($response->withStatus(500), [ 'error' => $errorMessage ]); } catch (\Exception $e) { return $this->jsonResponse($response->withStatus(500), [ 'error' => 'Internal server error: ' . $e->getMessage() ]); } } public function index(Request $request, Response $response, $args) { $queryParams = $request->getQueryParams(); // Get pagination parameters $page = max(1, (int)($queryParams['page'] ?? 1)); $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 48))); // Get search parameters $search = trim($queryParams['search'] ?? ''); // Get filter parameters $hasMovies = $queryParams['has_movies'] ?? null; $hasTvShows = $queryParams['has_tv_shows'] ?? null; $hasAdultVideos = $queryParams['has_adult_videos'] ?? null; // Get sort parameter $sort = $queryParams['sort'] ?? 'total_media_desc'; // total_media_desc, total_media_asc, name_asc, name_desc // Build the base query - simplified to ensure all actors are found $sql = " SELECT a.id, a.name, a.thumbnail_path, COALESCE(adult_counts.adult_video_count, 0) as adult_video_count, COALESCE(movie_counts.movie_count, 0) as movie_count, COALESCE(tv_counts.tv_show_count, 0) as tv_show_count, (COALESCE(adult_counts.adult_video_count, 0) + COALESCE(movie_counts.movie_count, 0) + COALESCE(tv_counts.tv_show_count, 0)) as total_media_count, GREATEST( COALESCE(adult_dates.latest_adult, '1900-01-01'), COALESCE(movie_dates.latest_movie, '1900-01-01'), COALESCE(tv_dates.latest_tv, '1900-01-01') ) as latest_media_date FROM actors a LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT adult_video_id) as adult_video_count FROM actor_adult_video GROUP BY actor_id ) adult_counts ON a.id = adult_counts.actor_id LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT movie_id) as movie_count FROM actor_movie GROUP BY actor_id ) movie_counts ON a.id = movie_counts.actor_id LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT tv_show_id) as tv_show_count FROM ( SELECT actor_id, tv_show_id FROM actor_tv_show UNION SELECT ate.actor_id, te.tv_show_id FROM actor_tv_episode ate JOIN tv_episodes te ON ate.tv_episode_id = te.id ) combined_tv GROUP BY actor_id ) tv_counts ON a.id = tv_counts.actor_id LEFT JOIN ( SELECT aav.actor_id, MAX(av.release_date) as latest_adult FROM actor_adult_video aav JOIN adult_videos av ON aav.adult_video_id = av.id GROUP BY aav.actor_id ) adult_dates ON a.id = adult_dates.actor_id LEFT JOIN ( SELECT am.actor_id, MAX(m.release_date) as latest_movie FROM actor_movie am JOIN movies m ON am.movie_id = m.id GROUP BY am.actor_id ) movie_dates ON a.id = movie_dates.actor_id LEFT JOIN ( SELECT combined_tv.actor_id, MAX(ts.first_air_date) as latest_tv FROM ( SELECT actor_id, tv_show_id FROM actor_tv_show UNION SELECT ate.actor_id, te.tv_show_id FROM actor_tv_episode ate JOIN tv_episodes te ON ate.tv_episode_id = te.id ) combined_tv JOIN tv_shows ts ON combined_tv.tv_show_id = ts.id GROUP BY combined_tv.actor_id ) tv_dates ON a.id = tv_dates.actor_id "; $params = []; $whereClauses = []; // Add search filter if (!empty($search)) { $whereClauses[] = "a.name LIKE :search"; $params['search'] = "%{$search}%"; } if (!empty($whereClauses)) { $sql .= ' WHERE ' . implode(' AND ', $whereClauses); } $sql .= " GROUP BY a.id"; // Add HAVING clause for filters that require aggregation $havingClauses = []; if ($hasMovies === '1') { $havingClauses[] = "movie_count > 0"; } if ($hasTvShows === '1') { $havingClauses[] = "tv_show_count > 0"; } if ($hasAdultVideos === '1') { $havingClauses[] = "adult_video_count > 0"; } if (!empty($havingClauses)) { $sql .= ' HAVING ' . implode(' AND ', $havingClauses); } // Add sorting $sortMap = [ 'total_media_desc' => 'total_media_count DESC, a.name ASC', 'total_media_asc' => 'total_media_count ASC, a.name ASC', 'name_asc' => 'a.name ASC', 'name_desc' => 'a.name DESC', 'latest_desc' => 'latest_media_date DESC NULLS LAST, a.name ASC', ]; $orderBy = $sortMap[$sort] ?? 'total_media_count DESC, a.name ASC'; $sql .= " ORDER BY {$orderBy}"; // Get total count for pagination - use a subquery to count the results $countSql = " SELECT COUNT(*) as count FROM ( SELECT a.id, COALESCE(adult_counts.adult_video_count, 0) as adult_video_count, COALESCE(movie_counts.movie_count, 0) as movie_count, COALESCE(tv_counts.tv_show_count, 0) as tv_show_count FROM actors a LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT adult_video_id) as adult_video_count FROM actor_adult_video GROUP BY actor_id ) adult_counts ON a.id = adult_counts.actor_id LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT movie_id) as movie_count FROM actor_movie GROUP BY actor_id ) movie_counts ON a.id = movie_counts.actor_id LEFT JOIN ( SELECT actor_id, COUNT(DISTINCT tv_show_id) as tv_show_count FROM ( SELECT actor_id, tv_show_id FROM actor_tv_show UNION SELECT ate.actor_id, te.tv_show_id FROM actor_tv_episode ate JOIN tv_episodes te ON ate.tv_episode_id = te.id ) combined_tv GROUP BY actor_id ) tv_counts ON a.id = tv_counts.actor_id "; // Add search filter if (!empty($whereClauses)) { $countSql .= ' WHERE ' . implode(' AND ', $whereClauses); } $countSql .= " GROUP BY a.id"; // Add HAVING clause for filters if (!empty($havingClauses)) { $countSql .= ' HAVING ' . implode(' AND ', $havingClauses); } $countSql .= ") as filtered_actors"; $countStmt = $this->pdo->prepare($countSql); foreach ($params as $key => $value) { $countStmt->bindValue($key, $value); } $countStmt->execute(); $countResult = $countStmt->fetch(PDO::FETCH_ASSOC); $totalCount = (int) ($countResult['count'] ?? 0); // Add pagination $offset = ($page - 1) * $perPage; $sql .= " LIMIT :limit OFFSET :offset"; // Execute main query $stmt = $this->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(); $actors = $stmt->fetchAll(PDO::FETCH_ASSOC); // Calculate pagination info $totalPages = ceil($totalCount / $perPage); $hasNextPage = $page < $totalPages; $hasPrevPage = $page > 1; return $this->view->render($response, 'actor/index.twig', [ 'title' => 'Actors & Performers', 'actors' => $actors, 'pagination' => [ 'current_page' => $page, 'per_page' => $perPage, 'total_pages' => $totalPages, 'total_items' => $totalCount, 'has_next' => $hasNextPage, 'has_prev' => $hasPrevPage, 'next_page' => $page + 1, 'prev_page' => $page - 1 ], 'search' => $search, 'sort' => $sort, 'sort_options' => [ 'total_media_desc' => 'Most Media', 'total_media_asc' => 'Least Media', 'name_asc' => 'Name A-Z', 'name_desc' => 'Name Z-A', 'latest_desc' => 'Recently Active' ], 'filters' => [ 'has_movies' => $hasMovies, 'has_tv_shows' => $hasTvShows, 'has_adult_videos' => $hasAdultVideos ] ]); } }