feat: Add Meilisearch full-text search with autocomplete and filters

- Add Meilisearch v1.6 service to docker-compose.yml
- Create search indexer with batch and incremental indexing
- Build search API with filters (category, duration, date, content type)
- Add autocomplete API with trending query suggestions
- Implement modern search UI with real-time autocomplete
- Add db_search_queries table for tracking trending searches
- Document setup and usage in docs/MEILISEARCH.md

Features:
- Blazing fast search (5-15ms typical)
- Advanced filters and faceted search
- Real-time autocomplete suggestions
- Trending queries tracking
- Responsive pagination
- Production-ready with proper error handling
This commit is contained in:
Krystie
2026-03-30 17:07:04 -07:00
parent 092a8bc7ce
commit 29c3c6fc8a
7 changed files with 1227 additions and 54 deletions
+328
View File
@@ -0,0 +1,328 @@
<?php
/**
* Meilisearch Indexer
* Indexes videos and live streams for full-text search
*/
require_once dirname(__DIR__, 2) . '/f_core/config.boot.php';
require_once dirname(__DIR__, 2) . '/f_core/f_classes/class.db.php';
require_once dirname(__DIR__, 2) . '/f_core/f_classes/class.logger.php';
class MeilisearchIndexer {
private $meili_host;
private $meili_key;
private $db;
private $logger;
public function __construct() {
$this->meili_host = getenv('MEILI_HOST') ?: 'http://meilisearch:7700';
$this->meili_key = getenv('MEILI_MASTER_KEY') ?: 'changeme_meilisearch_master_key';
$this->db = new VDatabase();
$this->logger = new VLogger('search_indexer');
}
/**
* Initialize Meilisearch index with settings
*/
public function initializeIndex() {
$this->logger->info("Initializing Meilisearch index");
// Create/update index settings
$settings = [
'searchableAttributes' => [
'title',
'description',
'tags',
'category',
'username'
],
'filterableAttributes' => [
'category',
'content_type',
'duration_range',
'upload_date',
'is_live',
'privacy'
],
'sortableAttributes' => [
'upload_date',
'view_count',
'like_count',
'duration'
],
'rankingRules' => [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness'
],
'stopWords' => ['the', 'a', 'an'],
'synonyms' => [
'video' => ['clip', 'recording'],
'live' => ['stream', 'broadcast']
]
];
$response = $this->apiRequest('PUT', '/indexes/videos/settings', $settings);
if ($response) {
$this->logger->info("Index settings updated successfully");
return true;
}
$this->logger->error("Failed to update index settings");
return false;
}
/**
* Index all videos (full reindex)
*/
public function indexAllVideos($batch_size = 100) {
$this->logger->info("Starting full video reindex");
$offset = 0;
$total_indexed = 0;
while (true) {
$videos = $this->fetchVideos($offset, $batch_size);
if (empty($videos)) {
break;
}
$documents = array_map([$this, 'transformVideoToDocument'], $videos);
if ($this->indexDocuments($documents)) {
$total_indexed += count($documents);
$this->logger->info("Indexed batch: " . count($documents) . " videos (Total: $total_indexed)");
} else {
$this->logger->error("Failed to index batch at offset $offset");
}
$offset += $batch_size;
}
$this->logger->info("Full reindex complete. Total videos indexed: $total_indexed");
return $total_indexed;
}
/**
* Index single video
*/
public function indexVideo($video_id) {
$video = $this->fetchVideoById($video_id);
if (!$video) {
$this->logger->warning("Video not found: $video_id");
return false;
}
$document = $this->transformVideoToDocument($video);
return $this->indexDocuments([$document]);
}
/**
* Remove video from index
*/
public function removeVideo($video_id) {
$response = $this->apiRequest('DELETE', "/indexes/videos/documents/$video_id");
if ($response) {
$this->logger->info("Removed video from index: $video_id");
return true;
}
return false;
}
/**
* Fetch videos from database
*/
private function fetchVideos($offset, $limit) {
$sql = "SELECT
v.video_id,
v.video_title as title,
v.video_description as description,
v.video_tags as tags,
v.video_category as category,
v.video_duration as duration,
v.video_views as view_count,
v.video_liked as like_count,
v.upload_date,
v.approved,
v.privacy,
u.usr_user as username,
u.usr_id as user_id,
'video' as content_type,
0 as is_live
FROM db_videofiles v
LEFT JOIN db_accountuser u ON v.usr_id = u.usr_id
WHERE v.approved = 1 AND v.privacy != 'private'
ORDER BY v.upload_date DESC
LIMIT $limit OFFSET $offset";
$result = $this->db->execute($sql);
if (!$result) {
return [];
}
return $this->db->resultsToArray($result);
}
/**
* Fetch single video by ID
*/
private function fetchVideoById($video_id) {
$sql = "SELECT
v.video_id,
v.video_title as title,
v.video_description as description,
v.video_tags as tags,
v.video_category as category,
v.video_duration as duration,
v.video_views as view_count,
v.video_liked as like_count,
v.upload_date,
v.approved,
v.privacy,
u.usr_user as username,
u.usr_id as user_id,
'video' as content_type,
0 as is_live
FROM db_videofiles v
LEFT JOIN db_accountuser u ON v.usr_id = u.usr_id
WHERE v.video_id = " . intval($video_id);
$result = $this->db->execute($sql);
if (!$result || $this->db->rowCount($result) == 0) {
return null;
}
return $this->db->fetch($result);
}
/**
* Transform video data to Meilisearch document
*/
private function transformVideoToDocument($video) {
// Determine duration range
$duration = intval($video['duration']);
if ($duration < 60) {
$duration_range = 'under_1min';
} elseif ($duration < 300) {
$duration_range = '1_5min';
} elseif ($duration < 600) {
$duration_range = '5_10min';
} elseif ($duration < 1200) {
$duration_range = '10_20min';
} else {
$duration_range = 'over_20min';
}
return [
'id' => 'video_' . $video['video_id'],
'video_id' => intval($video['video_id']),
'title' => $video['title'] ?? '',
'description' => $video['description'] ?? '',
'tags' => $video['tags'] ?? '',
'category' => $video['category'] ?? 'uncategorized',
'username' => $video['username'] ?? 'unknown',
'user_id' => intval($video['user_id'] ?? 0),
'content_type' => $video['content_type'] ?? 'video',
'is_live' => (bool)($video['is_live'] ?? false),
'duration' => $duration,
'duration_range' => $duration_range,
'view_count' => intval($video['view_count'] ?? 0),
'like_count' => intval($video['like_count'] ?? 0),
'upload_date' => strtotime($video['upload_date']),
'privacy' => $video['privacy'] ?? 'public'
];
}
/**
* Send documents to Meilisearch
*/
private function indexDocuments($documents) {
$response = $this->apiRequest('POST', '/indexes/videos/documents', $documents);
return $response !== null;
}
/**
* Make API request to Meilisearch
*/
private function apiRequest($method, $endpoint, $data = null) {
$url = $this->meili_host . $endpoint;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $this->meili_key,
'Content-Type: application/json'
]);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code >= 200 && $http_code < 300) {
return json_decode($response, true);
}
$this->logger->error("Meilisearch API error: HTTP $http_code - " . $response);
return null;
}
}
// CLI usage
if (php_sapi_name() === 'cli') {
$indexer = new MeilisearchIndexer();
$command = $argv[1] ?? 'help';
switch ($command) {
case 'init':
$indexer->initializeIndex();
break;
case 'reindex':
$indexer->initializeIndex();
$indexer->indexAllVideos();
break;
case 'index':
$video_id = $argv[2] ?? null;
if ($video_id) {
$indexer->indexVideo($video_id);
} else {
echo "Usage: php indexer.php index <video_id>\n";
}
break;
case 'remove':
$video_id = $argv[2] ?? null;
if ($video_id) {
$indexer->removeVideo($video_id);
} else {
echo "Usage: php indexer.php remove <video_id>\n";
}
break;
default:
echo "Meilisearch Indexer\n";
echo "Usage:\n";
echo " php indexer.php init - Initialize index settings\n";
echo " php indexer.php reindex - Full reindex of all videos\n";
echo " php indexer.php index <id> - Index single video\n";
echo " php indexer.php remove <id> - Remove video from index\n";
break;
}
}