mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
searcg revamp 😧
This commit is contained in:
@@ -723,16 +723,400 @@ class AdminController extends AdminBaseController
|
||||
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)
|
||||
{
|
||||
$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)
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,44 +20,162 @@ class SearchController extends Controller
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
$search = trim($queryParams['q'] ?? '');
|
||||
$type = $queryParams['type'] ?? 'all';
|
||||
$sort = $queryParams['sort'] ?? 'relevance';
|
||||
$page = (int)($queryParams['page'] ?? 1);
|
||||
$perPage = 30;
|
||||
|
||||
if (empty($search)) {
|
||||
return $this->view->render($response, 'search/index.twig', [
|
||||
'title' => 'Search',
|
||||
'search' => $search,
|
||||
'results' => []
|
||||
]);
|
||||
$results = [];
|
||||
$totalResults = 0;
|
||||
|
||||
if (!empty($search)) {
|
||||
$searchTerm = $this->pdo->quote("%$search%");
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Build sort clause
|
||||
$sortClause = match($sort) {
|
||||
'title' => 'title ASC',
|
||||
'date' => 'created_at DESC',
|
||||
'rating' => 'rating DESC',
|
||||
default => 'title ASC'
|
||||
};
|
||||
|
||||
// Search movies
|
||||
if ($type === 'all' || $type === 'movies') {
|
||||
$movieStmt = $this->pdo->query("
|
||||
SELECT m.*, s.display_name as source_name, 'movie' as type,
|
||||
m.created_at as added_date
|
||||
FROM movies m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
WHERE (m.title LIKE $searchTerm OR m.overview LIKE $searchTerm)
|
||||
ORDER BY $sortClause
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['movies'] = $movieStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for movies
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM movies m
|
||||
WHERE (m.title LIKE $searchTerm OR m.overview LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
// Search games
|
||||
if ($type === 'all' || $type === 'games') {
|
||||
$gameStmt = $this->pdo->query("
|
||||
SELECT g.*, 'game' as type, g.created_at as added_date
|
||||
FROM games g
|
||||
WHERE (g.title LIKE $searchTerm OR g.description LIKE $searchTerm)
|
||||
ORDER BY $sortClause
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['games'] = $gameStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for games
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM games g
|
||||
WHERE (g.title LIKE $searchTerm OR g.description LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
// Search TV shows
|
||||
if ($type === 'all' || $type === 'tvshows') {
|
||||
$tvStmt = $this->pdo->query("
|
||||
SELECT t.*, s.display_name as source_name, 'tvshow' as type,
|
||||
t.created_at as added_date
|
||||
FROM tv_shows t
|
||||
JOIN sources s ON t.source_id = s.id
|
||||
WHERE (t.title LIKE $searchTerm OR t.overview LIKE $searchTerm)
|
||||
ORDER BY $sortClause
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['tvshows'] = $tvStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for TV shows
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM tv_shows t
|
||||
WHERE (t.title LIKE $searchTerm OR t.overview LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
// Search music
|
||||
if ($type === 'all' || $type === 'music') {
|
||||
$musicStmt = $this->pdo->query("
|
||||
SELECT m.*, s.display_name as source_name, 'music' as type,
|
||||
m.created_at as added_date
|
||||
FROM music_albums m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
WHERE (m.title LIKE $searchTerm OR m.artist_name LIKE $searchTerm)
|
||||
ORDER BY $sortClause
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['music'] = $musicStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for music
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM music_albums m
|
||||
WHERE (m.title LIKE $searchTerm OR m.artist_name LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
// Search adult videos
|
||||
if ($type === 'all' || $type === 'adult') {
|
||||
$adultStmt = $this->pdo->query("
|
||||
SELECT a.*, s.display_name as source_name, 'adult' as type,
|
||||
a.created_at as added_date
|
||||
FROM adult_videos a
|
||||
JOIN sources s ON a.source_id = s.id
|
||||
WHERE (a.title LIKE $searchTerm)
|
||||
ORDER BY $sortClause
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['adult'] = $adultStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for adult videos
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM adult_videos a
|
||||
WHERE (a.title LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
// Search actors
|
||||
if ($type === 'all' || $type === 'actors') {
|
||||
$actorStmt = $this->pdo->query("
|
||||
SELECT a.*, 'actor' as type, a.created_at as added_date, a.thumbnail_path as thumbnail, a.name as title
|
||||
FROM actors a
|
||||
WHERE (a.name LIKE $searchTerm)
|
||||
|
||||
LIMIT $perPage OFFSET $offset
|
||||
");
|
||||
$results['actors'] = $actorStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Get total count for actors
|
||||
$countStmt = $this->pdo->query("
|
||||
SELECT COUNT(*) as count FROM actors a
|
||||
WHERE (a.name LIKE $searchTerm)
|
||||
");
|
||||
$totalResults += $countStmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
}
|
||||
|
||||
// Search across different media types
|
||||
$results = [];
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalResults / $perPage);
|
||||
|
||||
// Search movies (including adult videos)
|
||||
$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 $searchTerm OR m.overview LIKE $searchTerm)
|
||||
ORDER BY m.title
|
||||
LIMIT 20
|
||||
");
|
||||
$results['movies'] = $movieStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Search games
|
||||
$gameStmt = $this->pdo->query("
|
||||
SELECT g.*, 'game' as type
|
||||
FROM games g
|
||||
WHERE (g.title LIKE $searchTerm OR g.description LIKE $searchTerm)
|
||||
ORDER BY g.title
|
||||
LIMIT 20
|
||||
");
|
||||
$results['games'] = $gameStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
return $this->view->render($response, 'search/index.twig', [
|
||||
'title' => 'Search Results',
|
||||
'title' => !empty($search) ? 'Search Results' : 'Search',
|
||||
'search' => $search,
|
||||
'results' => $results
|
||||
'type' => $type,
|
||||
'sort' => $sort,
|
||||
'page' => $page,
|
||||
'totalPages' => $totalPages,
|
||||
'totalResults' => $totalResults,
|
||||
'results' => $results,
|
||||
'hasResults' => !empty(array_filter($results))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,4 +176,132 @@ class Actor extends Model
|
||||
'adult_video_id' => $adultVideoId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated actors with optional search and sorting
|
||||
*/
|
||||
public function getPaginated(PDO $pdo, int $page = 1, int $perPage = 20, string $search = '', string $sort = 'name_asc'): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Build WHERE clause for search
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$whereClause = 'WHERE name LIKE :search';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
$orderBy = match ($sort) {
|
||||
'name_desc' => 'name DESC',
|
||||
'media_desc' => '(SELECT COUNT(*) FROM actor_movie WHERE actor_id = actors.id) + (SELECT COUNT(*) FROM actor_tv_show WHERE actor_id = actors.id) + (SELECT COUNT(*) FROM actor_adult_video WHERE actor_id = actors.id) DESC',
|
||||
'media_asc' => '(SELECT COUNT(*) FROM actor_movie WHERE actor_id = actors.id) + (SELECT COUNT(*) FROM actor_tv_show WHERE actor_id = actors.id) + (SELECT COUNT(*) FROM actor_adult_video WHERE actor_id = actors.id) ASC',
|
||||
default => 'name ASC'
|
||||
};
|
||||
|
||||
// Get actors with their media counts
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
a.*,
|
||||
(SELECT COUNT(*) FROM actor_movie WHERE actor_id = a.id) as movie_count,
|
||||
(SELECT COUNT(*) FROM actor_tv_show WHERE actor_id = a.id) as tv_show_count,
|
||||
(SELECT COUNT(*) FROM actor_adult_video WHERE actor_id = a.id) as adult_video_count
|
||||
FROM actors a
|
||||
{$whereClause}
|
||||
ORDER BY {$orderBy}
|
||||
LIMIT :limit OFFSET :offset
|
||||
");
|
||||
|
||||
$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);
|
||||
|
||||
// Add relationships data for each actor
|
||||
foreach ($actors as &$actor) {
|
||||
$actor['movies'] = $this->getActorMovies($actor['id']);
|
||||
$actor['tvShows'] = $this->getActorTvShows($actor['id']);
|
||||
$actor['adultVideos'] = $this->getActorAdultVideos($actor['id']);
|
||||
}
|
||||
|
||||
return $actors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of actors with optional search
|
||||
*/
|
||||
public function getTotalCount(PDO $pdo, string $search = ''): int
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$whereClause = 'WHERE name LIKE :search';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) as count FROM actors {$whereClause}");
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
return (int)$stmt->fetch(PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movies for a specific actor (helper method)
|
||||
*/
|
||||
private function getActorMovies(int $actorId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT m.id, m.title
|
||||
FROM movies m
|
||||
JOIN actor_movie am ON m.id = am.movie_id
|
||||
WHERE am.actor_id = ?
|
||||
ORDER BY m.title
|
||||
");
|
||||
$stmt->execute([$actorId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TV shows for a specific actor (helper method)
|
||||
*/
|
||||
private function getActorTvShows(int $actorId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT ts.id, ts.title
|
||||
FROM tv_shows ts
|
||||
JOIN actor_tv_show ats ON ts.id = ats.tv_show_id
|
||||
WHERE ats.actor_id = ?
|
||||
ORDER BY ts.title
|
||||
");
|
||||
$stmt->execute([$actorId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adult videos for a specific actor (helper method)
|
||||
*/
|
||||
private function getActorAdultVideos(int $actorId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT av.id, av.title
|
||||
FROM adult_videos av
|
||||
JOIN actor_adult_video aav ON av.id = aav.adult_video_id
|
||||
WHERE aav.actor_id = ?
|
||||
ORDER BY av.title
|
||||
");
|
||||
$stmt->execute([$actorId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
511
resources/views/admin/actors/index.twig
Normal file
511
resources/views/admin/actors/index.twig
Normal file
@@ -0,0 +1,511 @@
|
||||
{% extends 'admin/layout.twig' %}
|
||||
|
||||
{% block title %}Manage Actors - Admin Panel - MediaLib{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">Manage Actors</h1>
|
||||
<p class="text-muted mb-0">View and manage actors, find and merge duplicates</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="{{ path_for('admin.actors.index') }}" class="btn btn-outline-primary {% if not filters.duplicates %}active{% endif %}">
|
||||
<i class="bi bi-people me-2"></i>All Actors
|
||||
</a>
|
||||
<a href="{{ path_for('admin.actors.index', {}, {'duplicates': 1}) }}" class="btn btn-outline-warning {% if filters.duplicates %}active{% endif %}">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Duplicates
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if flash.success %}
|
||||
<div class="alert alert-success">
|
||||
{{ flash.success }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filters.duplicates %}
|
||||
<!-- Duplicate Actors View -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0">Duplicate Actor Groups</h5>
|
||||
<small class="text-muted">Actors with similar names that may need to be merged</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success btn-sm" onclick="autoMergeAll()">
|
||||
<i class="bi bi-magic me-1"></i>Auto-Merge All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for group in actors %}
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning-subtle">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">{{ group.normalized_name|title }}</h6>
|
||||
<span class="badge bg-warning">{{ group.duplicate_count }} duplicates</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for actor in group.actors %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center p-2 border rounded">
|
||||
<div class="form-check me-3">
|
||||
<input class="form-check-input duplicate-checkbox"
|
||||
type="checkbox"
|
||||
value="{{ actor.id }}"
|
||||
id="actor_{{ actor.id }}"
|
||||
data-group="{{ group.normalized_name }}">
|
||||
</div>
|
||||
{% if actor.thumbnail_path %}
|
||||
<img src="/images/{{ actor.thumbnail_path }}"
|
||||
alt="{{ actor.name }}"
|
||||
class="rounded me-3"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="bg-light rounded d-flex align-items-center justify-content-center me-3"
|
||||
style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-person text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ actor.name }}</div>
|
||||
<small class="text-muted">
|
||||
{{ actor.stats.movie_count }} movies,
|
||||
{{ actor.stats.tv_show_count }} TV shows,
|
||||
{{ actor.stats.adult_video_count }} adult videos
|
||||
<span class="text-primary">({{ actor.stats.total_media_count }} total)</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted d-block">ID: {{ actor.id }}</small>
|
||||
{% if actor.thumbnail_path %}
|
||||
<i class="bi bi-image text-success" title="Has thumbnail"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm me-2"
|
||||
onclick="mergeSelected('{{ group.normalized_name }}')">
|
||||
<i class="bi bi-arrow-merge me-1"></i>Merge Selected
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
onclick="autoMergeGroup('{{ group.normalized_name }}')">
|
||||
<i class="bi bi-robot me-1"></i>Auto-Merge Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-check-circle text-success" style="font-size: 3rem;"></i>
|
||||
<h5 class="mt-3">No Duplicate Actors Found</h5>
|
||||
<p class="text-muted">All actors in your database appear to be unique.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination for duplicates -->
|
||||
{% if pagination.total > 1 %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.current > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'duplicates': 1,
|
||||
'page': pagination.current - 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}"
|
||||
aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% set start = max(1, pagination.current - 2) %}
|
||||
{% set end = min(pagination.total, pagination.current + 2) %}
|
||||
|
||||
{% if start > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'duplicates': 1,
|
||||
'page': 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">1</a>
|
||||
</li>
|
||||
{% if start > 2 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in start..end %}
|
||||
<li class="page-item {% if i == pagination.current %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'duplicates': 1,
|
||||
'page': i,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">
|
||||
{{ i }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if end < pagination.total %}
|
||||
{% if end < pagination.total - 1 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'duplicates': 1,
|
||||
'page': pagination.total,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">
|
||||
{{ pagination.total }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.current < pagination.total %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'duplicates': 1,
|
||||
'page': pagination.current + 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}"
|
||||
aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- All Actors View -->
|
||||
<form method="get" class="mb-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="search"
|
||||
placeholder="Search actors..."
|
||||
value="{{ filters.search }}">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="sort" class="form-select">
|
||||
<option value="name_asc" {{ filters.sort == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
|
||||
<option value="name_desc" {{ filters.sort == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
|
||||
<option value="media_desc" {{ filters.sort == 'media_desc' ? 'selected' : '' }}>Most Media</option>
|
||||
<option value="media_asc" {{ filters.sort == 'media_asc' ? 'selected' : '' }}>Least Media</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="btn-group w-100">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-funnel me-1"></i> Filter
|
||||
</button>
|
||||
<a href="{{ path_for('admin.actors.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="text-muted">
|
||||
Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total_items }} actors
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 60px;">Photo</th>
|
||||
<th>Name</th>
|
||||
<th>Movies</th>
|
||||
<th>TV Shows</th>
|
||||
<th>Adult Videos</th>
|
||||
<th>Total Media</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for actor in actors %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if actor.thumbnail_path %}
|
||||
<img src="/images/{{ actor.thumbnail_path }}" alt="{{ actor.name }}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 50%;">
|
||||
{% else %}
|
||||
<div class="bg-light d-flex align-items-center justify-content-center rounded-circle" style="width: 50px; height: 50px;">
|
||||
<i class="bi bi-person text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ actor.name }}</td>
|
||||
<td>{{ actor.movies|length }}</td>
|
||||
<td>{{ actor.tvShows|length }}</td>
|
||||
<td>{{ actor.adultVideos|length }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ actor.movies|length + actor.tvShows|length + actor.adultVideos|length }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ path_for('actors.show', {id: actor.id}) }}" class="btn btn-outline-info" target="_blank">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ path_for('actors.edit', {id: actor.id}) }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">
|
||||
<div class="text-muted">No actors found.</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination for all actors -->
|
||||
{% if pagination.total > 1 %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.current > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'page': pagination.current - 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}"
|
||||
aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% set start = max(1, pagination.current - 2) %}
|
||||
{% set end = min(pagination.total, pagination.current + 2) %}
|
||||
|
||||
{% if start > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'page': 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">1</a>
|
||||
</li>
|
||||
{% if start > 2 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in start..end %}
|
||||
<li class="page-item {% if i == pagination.current %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'page': i,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">
|
||||
{{ i }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if end < pagination.total %}
|
||||
{% if end < pagination.total - 1 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'page': pagination.total,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}">
|
||||
{{ pagination.total }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.current < pagination.total %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ path_for('admin.actors.index', {}, {
|
||||
'page': pagination.current + 1,
|
||||
'search': filters.search,
|
||||
'sort': filters.sort
|
||||
}) }}"
|
||||
aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function mergeSelected(groupName) {
|
||||
const checkboxes = document.querySelectorAll(`.duplicate-checkbox[data-group="${groupName}"]:checked`);
|
||||
const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
|
||||
if (selectedIds.length < 2) {
|
||||
alert('Please select at least 2 actors to merge.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to merge ${selectedIds.length} actors? The first selected actor will be kept as the master.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the first selected as master
|
||||
const masterId = selectedIds[0];
|
||||
const duplicates = selectedIds.slice(1);
|
||||
|
||||
fetch('{{ path_for("admin.actors.merge") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
master_actor_id: masterId,
|
||||
duplicate_actor_ids: duplicates
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while merging actors.');
|
||||
});
|
||||
}
|
||||
|
||||
function autoMergeGroup(groupName) {
|
||||
if (!confirm(`Are you sure you want to auto-merge all actors in the "${groupName}" group? This will automatically choose the best master actor.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ path_for("admin.actors.auto_merge") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actor_group_ids: [groupName]
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while auto-merging actors.');
|
||||
});
|
||||
}
|
||||
|
||||
function autoMergeAll() {
|
||||
const groups = Array.from(document.querySelectorAll('.duplicate-checkbox')).reduce((acc, cb) => {
|
||||
const group = cb.getAttribute('data-group');
|
||||
if (!acc.includes(group)) {
|
||||
acc.push(group);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (groups.length === 0) {
|
||||
alert('No duplicate groups found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to auto-merge all ${groups.length} duplicate groups? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ path_for("admin.actors.auto_merge") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actor_group_ids: groups
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while auto-merging actors.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -232,6 +232,12 @@
|
||||
<span>TV Shows</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path_for('admin.actors') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Actors</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path_for('admin.music') }}">
|
||||
<i class="bi bi-music-note-list"></i>
|
||||
|
||||
@@ -190,12 +190,18 @@
|
||||
<!-- Page Content -->
|
||||
<main class="flex min-h-screen">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="w-64 bg-slate-800 text-white shadow-lg hidden lg:block" x-data="{ collapsed: false }">
|
||||
<aside class="bg-slate-800 text-white shadow-lg hidden lg:block transition-all duration-300 ease-in-out" :class="collapsed ? 'w-16' : 'w-64'" x-data="{
|
||||
collapsed: JSON.parse(localStorage.getItem('leftSidebarCollapsed') || 'false'),
|
||||
toggle() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem('leftSidebarCollapsed', JSON.stringify(this.collapsed));
|
||||
}
|
||||
}">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div class="flex items-center justify-between p-4 border-b border-slate-700" :class="collapsed ? 'px-3' : 'p-4'">
|
||||
<h2 class="text-lg font-semibold" x-show="!collapsed">Library</h2>
|
||||
<button @click="collapsed = !collapsed" class="p-1 rounded-md hover:bg-slate-700">
|
||||
<button @click="toggle()" class="p-1 rounded-md hover:bg-slate-700">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path x-show="!collapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
<path x-show="collapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
@@ -267,7 +273,7 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- Collapsed State Icon -->
|
||||
<!-- Collapsed State Icons -->
|
||||
<div class="px-2 py-4" x-show="collapsed">
|
||||
<div class="space-y-2">
|
||||
<a href="{{ path_for('dashboard.index') }}" class="flex justify-center p-2 text-slate-300 hover:bg-slate-700 hover:text-white rounded-md" title="Dashboard">
|
||||
@@ -290,6 +296,39 @@
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_media_type_visible('tvshows') %}
|
||||
<a href="{{ path_for('tvshows.index') }}" class="flex justify-center p-2 text-slate-300 hover:bg-slate-700 hover:text-white rounded-md" title="TV Shows">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_media_type_visible('music') %}
|
||||
<a href="{{ path_for('music.index') }}" class="flex justify-center p-2 text-slate-300 hover:bg-slate-700 hover:text-white rounded-md" title="Music">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_media_type_visible('adult') %}
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-center p-2 text-slate-300">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<a href="{{ path_for('adult.index') }}" class="flex justify-center p-2 text-slate-400 hover:bg-slate-700 hover:text-white rounded-md" title="Adult Videos">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ path_for('actors.index') }}" class="flex justify-center p-2 text-slate-400 hover:bg-slate-700 hover:text-white rounded-md" title="Performers">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,12 +370,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<aside class="w-80 bg-white shadow-lg border-l border-gray-200 hidden xl:block" x-data="{ collapsed: false }">
|
||||
<aside class="bg-white shadow-lg border-l border-gray-200 hidden xl:block transition-all duration-300 ease-in-out" :class="collapsed ? 'w-12' : 'w-80'" x-data="{
|
||||
collapsed: JSON.parse(localStorage.getItem('rightSidebarCollapsed') || 'false'),
|
||||
toggle() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem('rightSidebarCollapsed', JSON.stringify(this.collapsed));
|
||||
}
|
||||
}">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200" :class="collapsed ? 'px-3' : 'p-4'">
|
||||
<h2 class="text-lg font-semibold text-gray-900" x-show="!collapsed">Details</h2>
|
||||
<button @click="collapsed = !collapsed" class="p-1 rounded-md hover:bg-gray-100">
|
||||
<button @click="toggle()" class="p-1 rounded-md hover:bg-gray-100">
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path x-show="!collapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path x-show="collapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
|
||||
@@ -1,110 +1,294 @@
|
||||
{% extends "layouts/app.twig" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-3">
|
||||
<div class="mb-4">
|
||||
<h1 class="display-4 fw-bold text-dark">Search</h1>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header Section -->
|
||||
<div class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Search</h1>
|
||||
{% if search %}
|
||||
<p class="mt-1 text-lg text-gray-600">Results for "{{ search }}"</p>
|
||||
{% else %}
|
||||
<p class="mt-1 text-lg text-gray-600">Discover your media collection</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if search and totalResults > 0 %}
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">{{ totalResults }} results found</p>
|
||||
<p class="text-xs text-gray-400">Page {{ page }} of {{ totalPages }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<form method="GET" class="space-y-4" x-data="{ showAdvanced: false }">
|
||||
<!-- Main Search Bar -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search movies, games, TV shows, music, and more..."
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 placeholder-gray-500"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Search
|
||||
</button>
|
||||
<button type="button" @click="showAdvanced = !showAdvanced" class="px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div x-show="showAdvanced" x-transition class="border-t border-gray-200 pt-4 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Media Type Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Media Type</label>
|
||||
<select name="type" class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="all" {{ type == 'all' ? 'selected' : '' }}>All Types</option>
|
||||
<option value="movies" {{ type == 'movies' ? 'selected' : '' }}>Movies</option>
|
||||
<option value="games" {{ type == 'games' ? 'selected' : '' }}>Games</option>
|
||||
<option value="tvshows" {{ type == 'tvshows' ? 'selected' : '' }}>TV Shows</option>
|
||||
<option value="music" {{ type == 'music' ? 'selected' : '' }}>Music</option>
|
||||
<option value="adult" {{ type == 'adult' ? 'selected' : '' }}>Adult Videos</option>
|
||||
<option value="actors" {{ type == 'actors' ? 'selected' : '' }}>Performers</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Sort By</label>
|
||||
<select name="sort" class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="relevance" {{ sort == 'relevance' ? 'selected' : '' }}>Relevance</option>
|
||||
<option value="title" {{ sort == 'title' ? 'selected' : '' }}>Title (A-Z)</option>
|
||||
<option value="date" {{ sort == 'date' ? 'selected' : '' }}>Date Added</option>
|
||||
<option value="rating" {{ sort == 'rating' ? 'selected' : '' }}>Rating</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<div class="flex items-end">
|
||||
<a href="{{ path_for('search.index') }}" class="w-full px-4 py-2 text-center border border-gray-300 text-gray-700 font-medium rounded-md hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{% if search %}
|
||||
<p class="lead text-muted">Search results for "{{ search }}"</p>
|
||||
{% if hasResults %}
|
||||
<!-- Results Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-6">
|
||||
{% for mediaType, items in results %}
|
||||
{% for item in items %}
|
||||
<div class="group relative bg-white rounded-lg shadow-sm hover:shadow-lg transition-shadow duration-200 overflow-hidden">
|
||||
<!-- Media Poster/Image -->
|
||||
<div class="aspect-[2/3] bg-gray-200 relative overflow-hidden">
|
||||
{% if item.poster_url %}
|
||||
<img src="/images/{{ item.poster_url }}" alt="{{ item.title or item.name }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200">
|
||||
{% elseif item.cover_image %}
|
||||
<img src="/images/playnite/{{ item.cover_image }}" alt="{{ item.title or item.name }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200">
|
||||
{% elseif item.image_url %}
|
||||
<img src="/images/playnite/{{ item.image_url }}" alt="{{ item.title or item.name }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200">
|
||||
{% elseif item.thumbnail %}
|
||||
<img src="{{ item.thumbnail }}" alt="{{ item.title or item.name }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-300 to-gray-400 flex items-center justify-center">
|
||||
<svg class="h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{% if item.type == 'movie' or item.type == 'tvshow' or item.type == 'adult' %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
{% elseif item.type == 'game' %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
{% elseif item.type == 'music' %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
{% elseif item.type == 'actor' %}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
{% endif %}
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Media Type Badge -->
|
||||
<div class="absolute top-2 left-2">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
|
||||
{% if item.type == 'movie' %}bg-blue-100 text-blue-800
|
||||
{% elseif item.type == 'game' %}bg-green-100 text-green-800
|
||||
{% elseif item.type == 'tvshow' %}bg-purple-100 text-purple-800
|
||||
{% elseif item.type == 'music' %}bg-yellow-100 text-yellow-800
|
||||
{% elseif item.type == 'adult' %}bg-red-100 text-red-800
|
||||
{% elseif item.type == 'actor' %}bg-pink-100 text-pink-800
|
||||
{% endif %}">
|
||||
{% if item.type == 'movie' %}Movie
|
||||
{% elseif item.type == 'game' %}Game
|
||||
{% elseif item.type == 'tvshow' %}TV Show
|
||||
{% elseif item.type == 'music' %}Music
|
||||
{% elseif item.type == 'adult' %}Adult
|
||||
{% elseif item.type == 'actor' %}Actor
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 opacity-0 bg-black group-hover:opacity-50 transition-all duration-200 flex items-center justify-center">
|
||||
<a href="
|
||||
{% if item.type == 'movie' %}{{ path_for('movies.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'game' %}{{ path_for('games.show', {'game_key': item.game_key}) }}
|
||||
{% elseif item.type == 'tvshow' %}{{ path_for('tvshows.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'music' %}{{ path_for('music.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'adult' %}{{ path_for('adult.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'actor' %}{{ path_for('actors.show', {'id': item.id}) }}
|
||||
{% endif %}"
|
||||
class="opacity-0 group-hover:opacity-100 bg-white text-gray-900 px-4 py-2 rounded-lg font-medium text-sm transition-all duration-200 hover:bg-gray-100">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium text-gray-900 text-sm leading-tight line-clamp-2 mb-1">
|
||||
<a href="
|
||||
{% if item.type == 'movie' %}{{ path_for('movies.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'game' %}{{ path_for('games.show', {'game_key': item.game_key}) }}
|
||||
{% elseif item.type == 'tvshow' %}{{ path_for('tvshows.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'music' %}{{ path_for('music.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'adult' %}{{ path_for('adult.show', {'id': item.id}) }}
|
||||
{% elseif item.type == 'actor' %}{{ path_for('actors.show', {'id': item.id}) }}
|
||||
{% endif %}"
|
||||
class="hover:text-blue-600 transition-colors">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if item.source_name %}
|
||||
<p class="text-xs text-gray-500 mb-2">{{ item.source_name }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.artist %}
|
||||
<p class="text-xs text-gray-600 mb-2">{{ item.artist }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.release_date %}
|
||||
<p class="text-xs text-gray-500">{{ item.release_date|date('Y') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.added_date %}
|
||||
<p class="text-xs text-gray-400">Added {{ item.added_date|date('M j, Y') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if totalPages > 1 %}
|
||||
<div class="mt-12 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700">
|
||||
Showing page {{ page }} of {{ totalPages }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if page > 1 %}
|
||||
<a href="?q={{ search|url_encode }}&type={{ type }}&sort={{ sort }}&page={{ page - 1 }}" class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% set startPage = max(1, page - 2) %}
|
||||
{% set endPage = min(totalPages, page + 2) %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="?q={{ search|url_encode }}&type={{ type }}&sort={{ sort }}&page=1" class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 py-2 text-sm text-gray-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
<a href="?q={{ search|url_encode }}&type={{ type }}&sort={{ sort }}&page={{ i }}" class="px-3 py-2 text-sm font-medium {{ i == page ? 'text-blue-600 bg-blue-50 border-blue-500' : 'text-gray-500 bg-white border-gray-300' }} border rounded-md hover:bg-gray-50">
|
||||
{{ i }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<span class="px-2 py-2 text-sm text-gray-400">...</span>
|
||||
{% endif %}
|
||||
<a href="?q={{ search|url_encode }}&type={{ type }}&sort={{ sort }}&page={{ totalPages }}" class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if page < totalPages %}
|
||||
<a href="?q={{ search|url_encode }}&type={{ type }}&sort={{ sort }}&page={{ page + 1 }}" class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No Results -->
|
||||
<div class="text-center py-16">
|
||||
<div class="mx-auto w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">No results found</h3>
|
||||
<p class="text-gray-500 mb-6">Try adjusting your search terms or filters</p>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<a href="{{ path_for('dashboard.index') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Browse Library
|
||||
</a>
|
||||
<a href="{{ path_for('search.index') }}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Clear Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="lead text-muted">Search across all your media collections</p>
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<div class="mx-auto w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg class="w-12 h-12 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">Search your media collection</h3>
|
||||
<p class="text-gray-500">Find movies, games, TV shows, music, and more</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form method="GET" class="mb-4">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search movies, games, and more..."
|
||||
class="form-control form-control-lg"
|
||||
autofocus
|
||||
>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="me-2" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if search and results %}
|
||||
<!-- Search Results -->
|
||||
{% if results.movies %}
|
||||
<div class="mb-4">
|
||||
<h2 class="h5 fw-semibold text-dark mb-3">Movies ({{ results.movies|length }})</h2>
|
||||
<div class="row g-3">
|
||||
{% for movie in results.movies %}
|
||||
<div class="col-12 col-sm-6 col-md-4 col-lg-1">
|
||||
<div class="card h-100">
|
||||
{% if movie.poster_url %}
|
||||
<div style="aspect-ratio: 2/3; background-color: #f8f9fa; border-radius: 0.375rem 0.375rem 0 0; overflow: hidden;">
|
||||
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-100 h-100" style="object-fit: cover;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-truncate">
|
||||
<a href="/media/movies/{{ movie.id }}" class="text-decoration-none">
|
||||
{{ movie.title }}
|
||||
</a>
|
||||
</h6>
|
||||
<p class="card-text small text-muted">{{ movie.source_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if results.games %}
|
||||
<div class="mb-4">
|
||||
<h2 class="h5 fw-semibold text-dark mb-3">Games ({{ results.games|length }})</h2>
|
||||
<div class="row g-3">
|
||||
{% for game in results.games %}
|
||||
<div class="col-12 col-sm-6 col-md-4 col-lg-1">
|
||||
<div class="card h-100">
|
||||
{% if game.cover_image %}
|
||||
<div style="aspect-ratio: 2/3; background-color: #f8f9fa; border-radius: 0.375rem 0.375rem 0 0; overflow: hidden;">
|
||||
<img src="{{ game.cover_image }}" alt="{{ game.name }}" class="w-100 h-100" style="object-fit: cover;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-truncate">
|
||||
<a href="/media/games/{{ game.game_key }}" class="text-decoration-none">
|
||||
{{ game.title }}
|
||||
</a>
|
||||
</h6>
|
||||
<p class="card-text small text-muted">{{ game.source_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not results.movies and not results.games %}
|
||||
<div class="text-center py-5">
|
||||
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="h5 fw-medium text-dark">No results found</h3>
|
||||
<p class="text-muted">Try adjusting your search terms or browse categories directly.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elseif search %}
|
||||
<div class="text-center py-5">
|
||||
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="h5 fw-medium text-dark">No results found</h3>
|
||||
<p class="text-muted">Try adjusting your search terms or browse categories directly.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -105,13 +105,19 @@ $app->group('', function (RouteCollectorProxy $group) {
|
||||
$group->get('/{id}/edit', [AdminController::class, 'editAdultVideo']);
|
||||
$group->post('/{id}', [AdminController::class, 'updateAdultVideo']);
|
||||
$group->post('/{id}/delete', [AdminController::class, 'deleteAdultVideo']);
|
||||
|
||||
|
||||
// Actor management routes
|
||||
$group->get('/{id}/actors', [AdminController::class, 'getAdultVideoActors']);
|
||||
$group->post('/{id}/actors', [AdminController::class, 'addActorToAdultVideo']);
|
||||
$group->delete('/{id}/actors/{actorId}', [AdminController::class, 'removeActorFromAdultVideo']);
|
||||
$group->get('/search-actors', [AdminController::class, 'searchActors']);
|
||||
});
|
||||
|
||||
$adminGroup->group('/actors', function (RouteCollectorProxy $group) {
|
||||
$group->get('', AdminController::class . ':actors')->setName('admin.actors.index');
|
||||
$group->post('/merge', AdminController::class . ':mergeActors')->setName('admin.actors.merge');
|
||||
$group->post('/auto-merge', AdminController::class . ':autoMergeActors')->setName('admin.actors.auto_merge');
|
||||
});
|
||||
|
||||
// Media Sources
|
||||
|
||||
|
||||
Reference in New Issue
Block a user