pdo = $pdo; } public function index(Request $request, Response $response, $args) { $sourceModel = new Source($this->pdo); $sources = $sourceModel->findAll(); $syncLogModel = new SyncLog($this->pdo); $recentSyncs = SyncLog::getRecent($this->pdo, 10); return $this->render($response, 'admin/index.twig', [ 'title' => 'Admin Dashboard', 'sources' => $sources, 'recent_syncs' => $recentSyncs ]); } /** * 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']; $syncType = $request->getQueryParams()['type'] ?? 'full'; $sourceModel = new Source($this->pdo); $source = $sourceModel->find($sourceId); if (!$source) { return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); } // Validate sync type based on source type if ($source['name'] === 'jellyfin') { $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'cleanup']; if (!in_array($syncType, $validSyncTypes)) { return $this->json($response, [ 'success' => false, 'message' => 'Invalid sync type for Jellyfin source. Valid types: ' . implode(', ', $validSyncTypes) ], 400); } } else { // For other sources, only allow full/incremental $validSyncTypes = ['full', 'incremental']; if (!in_array($syncType, $validSyncTypes)) { return $this->json($response, [ 'success' => false, 'message' => 'Invalid sync type. Valid types: ' . implode(', ', $validSyncTypes) ], 400); } } // Start sync in background (simplified - in production you'd use queues) $syncLogId = $this->startSync($source, $syncType); return $this->json($response, [ 'success' => true, 'sync_log_id' => $syncLogId, 'message' => 'Sync started successfully' ]); } public function syncStatus(Request $request, Response $response, $args) { $syncLogId = $args['id']; $syncLogModel = new SyncLog($this->pdo); $syncLog = $syncLogModel->find($syncLogId); if (!$syncLog) { return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); } return $this->json($response, [ 'id' => $syncLog['id'], 'status' => $syncLog['status'], 'sync_type' => $syncLog['sync_type'], 'total_items' => $syncLog['total_items'] ?? 0, 'processed_items' => $syncLog['processed_items'], 'new_items' => $syncLog['new_items'], 'updated_items' => $syncLog['updated_items'], 'deleted_items' => $syncLog['deleted_items'], 'started_at' => $syncLog['started_at'], 'completed_at' => $syncLog['completed_at'], 'message' => $syncLog['message'], 'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [], 'progress_percentage' => $this->calculateProgressPercentage($syncLog) ]); } private function calculateProgressPercentage(array $syncLog): float { $total = $syncLog['total_items'] ?? 0; if ($total <= 0) return 0; $processed = $syncLog['processed_items'] ?? 0; return min(100, round(($processed / $total) * 100, 2)); } public function settings(Request $request, Response $response, $args) { return $this->render($response, 'admin/settings.twig', [ 'title' => 'Admin Settings', 'current_route' => 'settings' ]); } public function sources(Request $request, Response $response, $args) { $sourceModel = new Source($this->pdo); $sources = $sourceModel->findAll(); return $this->render($response, 'admin/sources.twig', [ 'title' => 'Source Management', 'sources' => $sources, 'current_route' => 'sources' ]); } private function startSync(array $source, string $syncType): int { // Create sync log entry first $syncLogId = $this->createSyncLog($source, $syncType); // Start sync in background process $this->startBackgroundSync($source['id'], $syncType, $syncLogId); return $syncLogId; } private function createSyncLog(array $source, string $syncType): int { $data = [ 'source_id' => $source['id'], 'sync_type' => $syncType, 'status' => 'started', 'total_items' => 0, 'processed_items' => 0, 'new_items' => 0, 'updated_items' => 0, 'deleted_items' => 0, 'started_at' => date('Y-m-d H:i:s') ]; $columns = array_keys($data); $placeholders = array_map(fn($col) => ":$col", $columns); $sql = "INSERT INTO sync_logs (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; $stmt = $this->pdo->prepare($sql); $stmt->execute($data); return (int) $this->pdo->lastInsertId(); } private function startBackgroundSync(int $sourceId, string $syncType, int $syncLogId): void { $scriptPath = __DIR__ . '/../../sync-runner.php'; $command = sprintf( 'php %s %d %s %d > /dev/null 2>&1 &', escapeshellarg($scriptPath), $sourceId, escapeshellarg($syncType), $syncLogId ); // Execute the command in background if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { // Windows pclose(popen('start /B ' . $command, 'r')); } else { // Unix-like systems exec($command); } // */ // Update sync log to indicate it's running (this will be updated again by the script) $syncLogModel = new SyncLog($this->pdo); $syncLogModel->update($syncLogId, [ 'status' => 'running', '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'); } /** * Display actors management page with duplicate detection */ public function actors(Request $request, Response $response, $args) { $actorModel = new \App\Models\Actor($this->pdo); // Get query parameters $page = max(1, (int)($request->getQueryParams()['page'] ?? 1)); $search = trim($request->getQueryParams()['search'] ?? ''); $showDuplicates = $request->getQueryParams()['duplicates'] ?? false; $sort = trim($request->getQueryParams()['sort'] ?? 'name_asc'); $perPage = 20; $filters = [ 'search' => $search, 'duplicates' => $showDuplicates, 'sort' => $sort ]; if ($showDuplicates) { // Get duplicate actors $actors = $this->getDuplicateActors($page, $perPage); $totalActors = $this->getDuplicateActorsCount(); } else { // Get all actors with pagination $actors = $actorModel->getPaginated($this->pdo, $page, $perPage, $search, $sort); $totalActors = $actorModel->getTotalCount($this->pdo, $search); } $totalPages = max(1, ceil($totalActors / $perPage)); $currentPage = min($page, $totalPages); return $this->render($response, 'admin/actors/index.twig', [ 'title' => 'Manage Actors', 'actors' => $actors, 'filters' => $filters, 'pagination' => [ 'current' => $currentPage, 'total' => $totalPages, 'per_page' => $perPage, 'total_items' => $totalActors, 'from' => (($currentPage - 1) * $perPage) + 1, 'to' => min($currentPage * $perPage, $totalActors) ] ]); } /** * Get duplicate actors grouped by name */ private function getDuplicateActors(int $page = 1, int $perPage = 20): array { $offset = ($page - 1) * $perPage; $stmt = $this->pdo->prepare(" SELECT LOWER(TRIM(name)) as normalized_name, COUNT(*) as duplicate_count, GROUP_CONCAT(id ORDER BY id) as actor_ids, GROUP_CONCAT(name ORDER BY id) as actor_names, GROUP_CONCAT(COALESCE(thumbnail_path, '') ORDER BY id) as thumbnails, GROUP_CONCAT(COALESCE(metadata, '{}') ORDER BY id) as metadata_list FROM actors GROUP BY LOWER(TRIM(name)) HAVING COUNT(*) > 1 ORDER BY duplicate_count DESC, normalized_name ASC LIMIT ? OFFSET ? "); $stmt->execute([$perPage, $offset]); $duplicateGroups = $stmt->fetchAll(\PDO::FETCH_ASSOC); $result = []; foreach ($duplicateGroups as $group) { $actorIds = explode(',', $group['actor_ids']); $actorNames = explode(',', $group['actor_names']); $thumbnails = explode(',', $group['thumbnails']); $metadataList = explode(',', $group['metadata_list']); $actors = []; foreach ($actorIds as $index => $actorId) { $actors[] = [ 'id' => (int)$actorId, 'name' => $actorNames[$index], 'thumbnail_path' => $thumbnails[$index] ?: null, 'metadata' => json_decode($metadataList[$index] ?: '{}', true), 'stats' => $this->getActorStats((int)$actorId) ]; } $result[] = [ 'normalized_name' => $group['normalized_name'], 'duplicate_count' => (int)$group['duplicate_count'], 'actors' => $actors ]; } return $result; } /** * Get total count of duplicate actor groups */ private function getDuplicateActorsCount(): int { $stmt = $this->pdo->query(" SELECT COUNT(*) as count FROM ( SELECT LOWER(TRIM(name)) as normalized_name FROM actors GROUP BY LOWER(TRIM(name)) HAVING COUNT(*) > 1 ) as duplicates "); return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['count']; } /** * Get actor statistics */ private function getActorStats(int $actorId): array { $stmt = $this->pdo->prepare(" SELECT COUNT(DISTINCT am.movie_id) as movie_count, COUNT(DISTINCT ats.tv_show_id) as tv_show_count, COUNT(DISTINCT aav.adult_video_id) as adult_video_count, COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id) + 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_adult_video aav ON a.id = aav.actor_id WHERE a.id = ? "); $stmt->execute([$actorId]); return $stmt->fetch(\PDO::FETCH_ASSOC); } /** * Merge duplicate actors */ public function mergeActors(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(); } $masterActorId = (int)($data['master_actor_id'] ?? 0); $duplicateActorIds = array_map('intval', $data['duplicate_actor_ids'] ?? []); if (!$masterActorId || empty($duplicateActorIds)) { return $this->json($response, [ 'success' => false, 'message' => 'Master actor ID and duplicate actor IDs are required' ], 400); } // Verify master actor exists $masterActor = (new \App\Models\Actor($this->pdo))->find($masterActorId); if (!$masterActor) { return $this->json($response, [ 'success' => false, 'message' => 'Master actor not found' ], 404); } $this->pdo->beginTransaction(); try { $mergedCount = 0; $errors = []; foreach ($duplicateActorIds as $duplicateId) { if ($duplicateId === $masterActorId) { continue; // Skip if trying to merge master with itself } // Verify duplicate actor exists $duplicateActor = (new \App\Models\Actor($this->pdo))->find($duplicateId); if (!$duplicateActor) { $errors[] = "Actor ID {$duplicateId} not found"; continue; } // Move relationships from duplicate to master $this->moveActorRelationships($duplicateId, $masterActorId); // Delete the duplicate actor $stmt = $this->pdo->prepare("DELETE FROM actors WHERE id = ?"); $stmt->execute([$duplicateId]); $mergedCount++; } $this->pdo->commit(); return $this->json($response, [ 'success' => true, 'message' => "Successfully merged {$mergedCount} duplicate actors", 'merged_count' => $mergedCount, 'errors' => $errors ]); } catch (\Exception $e) { $this->pdo->rollBack(); return $this->json($response, [ 'success' => false, 'message' => 'Failed to merge actors: ' . $e->getMessage() ], 500); } } /** * Move all relationships from one actor to another */ private function moveActorRelationships(int $fromActorId, int $toActorId): void { // Move movie relationships $stmt = $this->pdo->prepare(" UPDATE actor_movie SET actor_id = ? WHERE actor_id = ? AND movie_id NOT IN ( SELECT movie_id FROM actor_movie WHERE actor_id = ? ) "); $stmt->execute([$toActorId, $fromActorId, $toActorId]); // Remove duplicate movie relationships that may have been created $stmt = $this->pdo->prepare(" DELETE FROM actor_movie WHERE actor_id = ? AND movie_id IN ( SELECT movie_id FROM ( SELECT movie_id FROM actor_movie WHERE actor_id = ? GROUP BY movie_id HAVING COUNT(*) > 1 ) as duplicates ) "); $stmt->execute([$fromActorId, $fromActorId]); // Move TV show relationships $stmt = $this->pdo->prepare(" UPDATE actor_tv_show SET actor_id = ? WHERE actor_id = ? AND tv_show_id NOT IN ( SELECT tv_show_id FROM actor_tv_show WHERE actor_id = ? ) "); $stmt->execute([$toActorId, $fromActorId, $toActorId]); // Remove duplicate TV show relationships $stmt = $this->pdo->prepare(" DELETE FROM actor_tv_show WHERE actor_id = ? AND tv_show_id IN ( SELECT tv_show_id FROM ( SELECT tv_show_id FROM actor_tv_show WHERE actor_id = ? GROUP BY tv_show_id HAVING COUNT(*) > 1 ) as duplicates ) "); $stmt->execute([$fromActorId, $fromActorId]); // Move adult video relationships $stmt = $this->pdo->prepare(" UPDATE actor_adult_video SET actor_id = ? WHERE actor_id = ? AND adult_video_id NOT IN ( SELECT adult_video_id FROM actor_adult_video WHERE actor_id = ? ) "); $stmt->execute([$toActorId, $fromActorId, $toActorId]); // Remove duplicate adult video relationships $stmt = $this->pdo->prepare(" DELETE FROM actor_adult_video WHERE actor_id = ? AND adult_video_id IN ( SELECT adult_video_id FROM ( SELECT adult_video_id FROM actor_adult_video WHERE actor_id = ? GROUP BY adult_video_id HAVING COUNT(*) > 1 ) as duplicates ) "); $stmt->execute([$fromActorId, $fromActorId]); } /** * Auto-merge duplicate actors (chooses master based on media count and thumbnail) */ public function autoMergeActors(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(); } $actorGroupIds = $data['actor_group_ids'] ?? []; if (empty($actorGroupIds)) { return $this->json($response, [ 'success' => false, 'message' => 'No actor groups specified for auto-merge' ], 400); } $this->pdo->beginTransaction(); try { $totalMerged = 0; $groupsProcessed = 0; foreach ($actorGroupIds as $groupId) { $actors = $this->getActorsByNormalizedName($groupId); if (count($actors) <= 1) { continue; } // Choose master actor (prefer one with thumbnail, then most media associations) $masterActor = $this->chooseMasterActor($actors); $duplicateIds = array_filter(array_column($actors, 'id'), fn($id) => $id !== $masterActor['id']); // Merge duplicates into master foreach ($duplicateIds as $duplicateId) { $this->moveActorRelationships($duplicateId, $masterActor['id']); // Delete duplicate $stmt = $this->pdo->prepare("DELETE FROM actors WHERE id = ?"); $stmt->execute([$duplicateId]); } $totalMerged += count($duplicateIds); $groupsProcessed++; } $this->pdo->commit(); return $this->json($response, [ 'success' => true, 'message' => "Auto-merged {$totalMerged} actors across {$groupsProcessed} groups", 'merged_count' => $totalMerged, 'groups_processed' => $groupsProcessed ]); } catch (\Exception $e) { $this->pdo->rollBack(); return $this->json($response, [ 'success' => false, 'message' => 'Failed to auto-merge actors: ' . $e->getMessage() ], 500); } } /** * Get actors by normalized name */ private function getActorsByNormalizedName(string $normalizedName): array { $stmt = $this->pdo->prepare(" SELECT id, name, thumbnail_path, metadata FROM actors WHERE LOWER(TRIM(name)) = ? ORDER BY id "); $stmt->execute([$normalizedName]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } /** * Choose the best master actor from a group */ private function chooseMasterActor(array $actors): array { // First, prefer actors with thumbnails $withThumbnails = array_filter($actors, fn($actor) => !empty($actor['thumbnail_path'])); if (!empty($withThumbnails)) { $actors = $withThumbnails; } // Then prefer the one with most media associations $maxMediaCount = 0; $masterActor = $actors[0]; foreach ($actors as $actor) { $stats = $this->getActorStats($actor['id']); $mediaCount = $stats['total_media_count']; if ($mediaCount > $maxMediaCount) { $maxMediaCount = $mediaCount; $masterActor = $actor; } } return $masterActor; } }