Files
MediaCollectorLibary/app/Controllers/AdminController.php
2025-11-10 05:06:26 +01:00

1137 lines
39 KiB
PHP

<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Source;
use App\Models\SyncLog;
use App\Services\SteamSyncService;
use App\Services\JellyfinSyncService;
use App\Services\StashSyncService;
use App\Services\XbvrSyncService;
use App\Services\AdultSyncService;
use App\Services\ExophaseSyncService;
use PDO;
use Slim\Views\Twig;
class AdminController extends AdminBaseController
{
protected PDO $pdo;
public function __construct(PDO $pdo, Twig $view)
{
parent::__construct($pdo, $view);
$this->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', 'music', '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;
}
}