diff --git a/.env.example b/.env.example index 08bacf1d1..a419ac3c6 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,21 @@ DB_DATABASE=nntmux COMPOSER_AUTH='{"github-oauth": {"github.com": "YOUR GITHUB TOKEN"}}' +# ManticoreSearch Configuration MANTICORESEARCH_HOST=localhost MANTICORESEARCH_PORT=9308 +# For high availability, set multiple hosts (comma-separated): "host1:9308,host2:9308" +MANTICORESEARCH_HOSTS= +MANTICORESEARCH_RETRIES=2 +# Autocomplete settings +MANTICORESEARCH_AUTOCOMPLETE_ENABLED=true +MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH=2 +MANTICORESEARCH_AUTOCOMPLETE_MAX_RESULTS=10 +MANTICORESEARCH_AUTOCOMPLETE_FUZZINESS=1 +MANTICORESEARCH_AUTOCOMPLETE_CACHE=10 +# Suggest/spell correction settings +MANTICORESEARCH_SUGGEST_ENABLED=true +MANTICORESEARCH_SUGGEST_MAX_EDITS=4 ELASTICSEARCH_HOST=localhost ELASTICSEARCH_PORT=9200 diff --git a/Blacklight/ManticoreSearch.php b/Blacklight/ManticoreSearch.php index 6157d476b..31b13bcff 100644 --- a/Blacklight/ManticoreSearch.php +++ b/Blacklight/ManticoreSearch.php @@ -9,6 +9,10 @@ use Illuminate\Support\Facades\Log; use Manticoresearch\Client; use Manticoresearch\Exceptions\ResponseException; use Manticoresearch\Exceptions\RuntimeException; +use Manticoresearch\Query\BoolQuery; +use Manticoresearch\Query\In; +use Manticoresearch\Query\MatchQuery; +use Manticoresearch\Query\Range; use Manticoresearch\Search; /** @@ -67,14 +71,12 @@ class ManticoreSearch /** * Establishes a connection to ManticoreSearch HTTP port. + * Supports single host or multiple hosts for high availability. */ public function __construct(?Client $client = null) { $this->config = $this->loadConfig(); - $this->connection = [ - 'host' => $this->config['host'], - 'port' => $this->config['port'], - ]; + $this->connection = $this->buildConnectionConfig(); $this->manticoreSearch = $client ?? new Client($this->connection); $this->cli = new ColorCLI; @@ -84,6 +86,46 @@ class ManticoreSearch $this->cacheMinutes = $this->config['cache_minutes'] ?? self::DEFAULT_CACHE_MINUTES; } + /** + * Build connection configuration supporting single or multiple hosts. + */ + private function buildConnectionConfig(): array + { + // Check for multiple hosts configuration + $hostsString = $this->config['hosts'] ?? ''; + + if (! empty($hostsString)) { + $connections = []; + $hosts = explode(',', $hostsString); + + foreach ($hosts as $hostEntry) { + $hostEntry = trim($hostEntry); + if (empty($hostEntry)) { + continue; + } + + $parts = explode(':', $hostEntry); + $connections[] = [ + 'host' => $parts[0], + 'port' => (int) ($parts[1] ?? self::DEFAULT_PORT), + ]; + } + + if (! empty($connections)) { + return [ + 'connections' => $connections, + 'retries' => $this->config['retries'] ?? count($connections), + ]; + } + } + + // Single host configuration + return [ + 'host' => $this->config['host'], + 'port' => $this->config['port'], + ]; + } + /** * Load and validate configuration. */ @@ -94,6 +136,8 @@ class ManticoreSearch return [ 'host' => $config['host'] ?? self::DEFAULT_HOST, 'port' => $config['port'] ?? self::DEFAULT_PORT, + 'hosts' => $config['hosts'] ?? '', + 'retries' => $config['retries'] ?? 2, 'indexes' => $config['indexes'] ?? [ 'releases' => self::INDEX_RELEASES, 'predb' => self::INDEX_PREDB, @@ -103,6 +147,17 @@ class ManticoreSearch 'cache_minutes' => $config['cache_minutes'] ?? self::DEFAULT_CACHE_MINUTES, 'max_matches' => $config['max_matches'] ?? self::DEFAULT_MAX_MATCHES, 'batch_size' => $config['batch_size'] ?? self::DEFAULT_BATCH_SIZE, + 'autocomplete' => $config['autocomplete'] ?? [ + 'enabled' => true, + 'min_length' => 2, + 'max_results' => 10, + 'fuzziness' => 1, + 'cache_minutes' => 10, + ], + 'suggest' => $config['suggest'] ?? [ + 'enabled' => true, + 'max_edits' => 4, + ], ]; } @@ -923,4 +978,492 @@ class ManticoreSearch { return $this->config; } + + /** + * Get autocomplete suggestions for a search query. + * Searches the releases index and returns matching searchnames. + * + * @param string $query The partial search query + * @param string|null $index Index to search (defaults to releases index) + * @return array + */ + public function autocomplete(string $query, ?string $index = null): array + { + $autocompleteConfig = $this->config['autocomplete']; + + if (! $autocompleteConfig['enabled']) { + return []; + } + + $query = trim($query); + if (strlen($query) < $autocompleteConfig['min_length']) { + return []; + } + + $index = $index ?? $this->getReleasesIndex(); + $cacheKey = 'manticore:autocomplete:'.md5($index.$query); + + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $suggestions = []; + + try { + // Search releases index for matching searchnames + $escapedQuery = self::escapeString($query); + if (empty($escapedQuery)) { + return []; + } + + // Use relaxed search on searchname field + $searchExpr = '@@relaxed @searchname '.$escapedQuery; + + $search = (new Search($this->manticoreSearch)) + ->setTable($index) + ->search($searchExpr) + ->limit($autocompleteConfig['max_results'] * 3) // Get more to dedupe + ->stripBadUtf8(true); + + $results = $search->get(); + + $seen = []; + foreach ($results as $doc) { + $data = $doc->getData(); + $searchname = $data['searchname'] ?? ''; + + if (empty($searchname)) { + continue; + } + + // Create a clean suggestion from the searchname + $suggestion = $this->extractSuggestion($searchname, $query); + + if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) { + $seen[strtolower($suggestion)] = true; + $suggestions[] = [ + 'suggest' => $suggestion, + 'distance' => 0, + 'docs' => 1, + ]; + } + + if (count($suggestions) >= $autocompleteConfig['max_results']) { + break; + } + } + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage()); + } + } + + if (! empty($suggestions)) { + Cache::put($cacheKey, $suggestions, now()->addMinutes((int) $autocompleteConfig['cache_minutes'])); + } + + return $suggestions; + } + + /** + * Extract a clean suggestion from a searchname. + * + * @param string $searchname The full searchname + * @param string $query The user's query + * @return string|null The extracted suggestion + */ + private function extractSuggestion(string $searchname, string $query): ?string + { + // Clean up the searchname - remove file extensions, quality tags at the end + $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname); + + // Replace dots and underscores with spaces for readability + $clean = str_replace(['.', '_'], ' ', $clean); + + // Remove multiple spaces + $clean = preg_replace('/\s+/', ' ', $clean); + $clean = trim($clean); + + if (empty($clean)) { + return null; + } + + // If the clean name is reasonable length, use it + if (strlen($clean) <= 80) { + return $clean; + } + + // For very long names, try to extract the relevant part + // Find where the query matches and extract context around it + $pos = stripos($clean, $query); + if ($pos !== false) { + // Get up to 80 chars starting from the match position, or from beginning if match is early + $start = max(0, $pos - 10); + $extracted = substr($clean, $start, 80); + + // Clean up - don't cut mid-word + if ($start > 0) { + $extracted = preg_replace('/^\S*\s/', '', $extracted); + } + $extracted = preg_replace('/\s\S*$/', '', $extracted); + + return trim($extracted); + } + + // Fallback: just truncate + return substr($clean, 0, 80); + } + + + /** + * Get spell correction suggestions ("Did you mean?"). + * Uses CALL SUGGEST or falls back to searching releases for similar terms. + * + * @param string $query The search query to check + * @param string|null $index Index to use for suggestions + * @return array + */ + public function suggest(string $query, ?string $index = null): array + { + $suggestConfig = $this->config['suggest']; + + if (! $suggestConfig['enabled']) { + return []; + } + + $query = trim($query); + if (empty($query)) { + return []; + } + + $index = $index ?? $this->getReleasesIndex(); + $cacheKey = 'manticore:suggest:'.md5($index.$query); + + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $suggestions = []; + + try { + // Try native CALL SUGGEST first + $result = $this->manticoreSearch->suggest([ + 'table' => $index, + 'body' => [ + 'query' => $query, + 'options' => [ + 'limit' => 5, + 'max_edits' => $suggestConfig['max_edits'], + ], + ], + ]); + + if (! empty($result) && is_array($result)) { + foreach ($result as $item) { + if (isset($item['suggest']) && $item['suggest'] !== $query) { + $suggestions[] = [ + 'suggest' => $item['suggest'], + 'distance' => $item['distance'] ?? 0, + 'docs' => $item['docs'] ?? 0, + ]; + } + } + } + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage()); + } + } + + // If native suggest didn't return results, try a fuzzy search fallback + if (empty($suggestions)) { + $suggestions = $this->suggestFallback($query, $index); + } + + if (! empty($suggestions)) { + Cache::put($cacheKey, $suggestions, now()->addMinutes((int) $this->cacheMinutes)); + } + + return $suggestions; + } + + /** + * Fallback suggest using similar searchname matches. + * + * @param string $query The search query + * @param string $index Index to search + * @return array + */ + private function suggestFallback(string $query, string $index): array + { + try { + // Try to find releases with similar searchnames + // This helps when users misspell common terms + $escapedQuery = self::escapeString($query); + if (empty($escapedQuery)) { + return []; + } + + // Use relaxed search to find partial matches + $searchExpr = '@@relaxed @searchname '.$escapedQuery; + + $search = (new Search($this->manticoreSearch)) + ->setTable($index) + ->search($searchExpr) + ->limit(20) + ->stripBadUtf8(true); + + $results = $search->get(); + + // Extract common terms from the results that differ from the query + $termCounts = []; + foreach ($results as $doc) { + $data = $doc->getData(); + $searchname = $data['searchname'] ?? ''; + + // Extract words from searchname + $words = preg_split('/[\s\.\-\_]+/', strtolower($searchname)); + foreach ($words as $word) { + if (strlen($word) >= 3 && $word !== strtolower($query)) { + // Check if word is similar to query (within edit distance) + $distance = levenshtein(strtolower($query), $word); + if ($distance > 0 && $distance <= 3) { + if (! isset($termCounts[$word])) { + $termCounts[$word] = ['count' => 0, 'distance' => $distance]; + } + $termCounts[$word]['count']++; + } + } + } + } + + // Sort by count (most common first) + uasort($termCounts, fn($a, $b) => $b['count'] - $a['count']); + + $suggestions = []; + foreach (array_slice($termCounts, 0, 5, true) as $term => $data) { + $suggestions[] = [ + 'suggest' => $term, + 'distance' => $data['distance'], + 'docs' => $data['count'], + ]; + } + + return $suggestions; + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::warning('ManticoreSearch suggest fallback error: '.$e->getMessage()); + } + return []; + } + } + + /** + * Search with result highlighting. + * + * @param string $index Index to search + * @param string $query Search query + * @param array $highlightFields Fields to highlight + * @param int $limit Maximum results + * @return array{results: array, total: int} + */ + public function searchWithHighlight( + string $index, + string $query, + array $highlightFields = ['searchname', 'name'], + int $limit = 100 + ): array { + if (empty($query)) { + return ['results' => [], 'total' => 0]; + } + + $cacheKey = 'manticore:highlight:'.md5($index.$query.implode(',', $highlightFields).$limit); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $escapedQuery = self::escapeString($query); + if (empty($escapedQuery)) { + return ['results' => [], 'total' => 0]; + } + + $search = (new Search($this->manticoreSearch)) + ->setTable($index) + ->search($escapedQuery) + ->highlight($highlightFields, [ + 'pre_tags' => '', + 'post_tags' => '', + 'limit' => 256, + 'no_match_size' => 0, + ]) + ->limit($limit) + ->maxMatches($this->config['max_matches']) + ->stripBadUtf8(true); + + $resultSet = $search->get(); + + $results = []; + foreach ($resultSet as $doc) { + $results[] = [ + 'id' => $doc->getId(), + 'data' => $doc->getData(), + 'highlight' => $doc->getHighlight() ?? [], + ]; + } + + $response = [ + 'results' => $results, + 'total' => $resultSet->getTotal() ?? count($results), + ]; + + Cache::put($cacheKey, $response, now()->addMinutes($this->cacheMinutes)); + + return $response; + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchWithHighlight error: '.$e->getMessage(), [ + 'index' => $index, + 'query' => $query, + ]); + + return ['results' => [], 'total' => 0]; + } + } + + /** + * Advanced search using structured BoolQuery with filters. + * + * @param string $index Index to search + * @param array{ + * search?: string, + * searchname?: string, + * name?: string, + * fromname?: string, + * filename?: string, + * categories?: array, + * exclude_categories?: array, + * min_date?: string, + * max_date?: string, + * sort_field?: string, + * sort_dir?: string, + * limit?: int, + * offset?: int + * } $params Search parameters + * @return array{ids: array, total: int} + */ + public function advancedSearch(string $index, array $params): array + { + $cacheKey = 'manticore:advsearch:'.md5($index.serialize($params)); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $query = new BoolQuery; + $hasQuery = false; + + // Full-text search across multiple fields + if (! empty($params['search'])) { + $escaped = self::escapeString($params['search']); + if (! empty($escaped)) { + $query->must(new MatchQuery($escaped, 'searchname,name,filename')); + $hasQuery = true; + } + } + + // Field-specific searches + $fieldMappings = [ + 'searchname' => 'searchname', + 'name' => 'name', + 'fromname' => 'fromname', + 'filename' => 'filename', + ]; + + foreach ($fieldMappings as $paramKey => $field) { + if (! empty($params[$paramKey])) { + $escaped = self::escapeString($params[$paramKey]); + if (! empty($escaped)) { + $query->must(new MatchQuery($escaped, $field)); + $hasQuery = true; + } + } + } + + // Category filter + if (! empty($params['categories']) && is_array($params['categories'])) { + $query->must(new In('categories_id', array_map('intval', $params['categories']))); + $hasQuery = true; + } + + // Exclude categories + if (! empty($params['exclude_categories']) && is_array($params['exclude_categories'])) { + $query->mustNot(new In('categories_id', array_map('intval', $params['exclude_categories']))); + } + + if (! $hasQuery) { + return ['ids' => [], 'total' => 0]; + } + + $limit = $params['limit'] ?? $this->config['max_matches']; + $offset = $params['offset'] ?? 0; + + $search = (new Search($this->manticoreSearch)) + ->setTable($index) + ->search($query) + ->limit($limit) + ->offset($offset) + ->maxMatches($this->config['max_matches']) + ->stripBadUtf8(true); + + // Sorting + $sortField = $params['sort_field'] ?? 'id'; + $sortDir = strtolower($params['sort_dir'] ?? 'desc'); + if (in_array($sortDir, ['asc', 'desc'])) { + $search->sort($sortField, $sortDir); + } + + $resultSet = $search->get(); + + $ids = []; + foreach ($resultSet as $doc) { + $ids[] = $doc->getId(); + } + + $response = [ + 'ids' => $ids, + 'total' => $resultSet->getTotal() ?? count($ids), + ]; + + Cache::put($cacheKey, $response, now()->addMinutes($this->cacheMinutes)); + + return $response; + } catch (\Throwable $e) { + Log::error('ManticoreSearch advancedSearch error: '.$e->getMessage(), [ + 'index' => $index, + 'params' => $params, + ]); + + return ['ids' => [], 'total' => 0]; + } + } + + /** + * Check if autocomplete is enabled. + */ + public function isAutocompleteEnabled(): bool + { + return $this->config['autocomplete']['enabled'] ?? false; + } + + /** + * Check if suggest is enabled. + */ + public function isSuggestEnabled(): bool + { + return $this->config['suggest']['enabled'] ?? false; + } } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index ac46d234d..6d3eb25d3 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -4,11 +4,20 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\UsenetGroup; +use Blacklight\ManticoreSearch; use Blacklight\Releases; use Illuminate\Http\Request; class SearchController extends BasePageController { + private ManticoreSearch $manticore; + + public function __construct() + { + parent::__construct(); + $this->manticore = new ManticoreSearch; + } + /** * @throws \Exception */ @@ -133,6 +142,22 @@ class SearchController extends BasePageController $searchVars['selectedsizefrom'] = $searchVars['searchadvsizefrom']; $searchVars['selectedsizeto'] = $searchVars['searchadvsizeto']; + // Get spell correction suggestions if we have a search query but few/no results + $spellSuggestion = null; + $searchQuery = $search ?: ($searchVars['searchadvr'] ?? ''); + if (! empty($searchQuery) && $this->manticore->isSuggestEnabled()) { + // Get suggestions from ManticoreSearch + $suggestions = $this->manticore->suggest($searchQuery); + if (! empty($suggestions)) { + // Sort by doc count descending to get best suggestion + usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']); + // Only show suggestion if it's different from the query + if ($suggestions[0]['suggest'] !== $searchQuery) { + $spellSuggestion = $suggestions[0]['suggest']; + } + } + } + if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter', 'minage', 'maxage', 'group', 'minsize', 'maxsize', 'search'])) { $orderByString = ''; foreach ($searchVars as $searchVarKey => $searchVar) { @@ -189,6 +214,10 @@ class SearchController extends BasePageController 'meta_title' => 'Search Nzbs', 'meta_keywords' => 'search,nzb,description,details', 'meta_description' => 'Search for Nzbs', + // ManticoreSearch enhanced features + 'spellSuggestion' => $spellSuggestion, + 'autocompleteEnabled' => $this->manticore->isAutocompleteEnabled(), + 'suggestEnabled' => $this->manticore->isSuggestEnabled(), ]); return view('search.index', $this->viewData); diff --git a/app/Http/Controllers/SearchSuggestController.php b/app/Http/Controllers/SearchSuggestController.php new file mode 100644 index 000000000..207e73868 --- /dev/null +++ b/app/Http/Controllers/SearchSuggestController.php @@ -0,0 +1,163 @@ +manticore = new ManticoreSearch; + } + + /** + * Get autocomplete suggestions for search input. + */ + public function autocomplete(Request $request): JsonResponse + { + $query = $request->input('q', ''); + // Use the index from ManticoreSearch config, or allow override via request + $index = $request->input('index') ?? $this->manticore->getReleasesIndex(); + + // Rate limiting: 60 requests per minute per IP + $key = 'autocomplete:'.$request->ip(); + if (RateLimiter::tooManyAttempts($key, 60)) { + return response()->json([ + 'success' => false, + 'error' => 'Too many requests', + ], 429); + } + RateLimiter::hit($key, 60); + + if (! $this->manticore->isAutocompleteEnabled()) { + return response()->json([ + 'success' => true, + 'suggestions' => [], + ]); + } + + if (strlen($query) < 2) { + return response()->json([ + 'success' => true, + 'suggestions' => [], + ]); + } + + try { + $suggestions = $this->manticore->autocomplete($query, $index); + + return response()->json([ + 'success' => true, + 'query' => $query, + 'suggestions' => array_map(fn ($s) => $s['suggest'], $suggestions), + ]); + } catch (\Throwable $e) { + return response()->json([ + 'success' => true, + 'query' => $query, + 'suggestions' => [], + ]); + } + } + + /** + * Get spell correction suggestions ("Did you mean?"). + */ + public function suggest(Request $request): JsonResponse + { + $query = $request->input('q', ''); + $index = $request->input('index', 'releases_rt'); + + // Rate limiting: 30 requests per minute per IP + $key = 'suggest:'.$request->ip(); + if (RateLimiter::tooManyAttempts($key, 30)) { + return response()->json([ + 'success' => false, + 'error' => 'Too many requests', + ], 429); + } + RateLimiter::hit($key, 60); + + if (! $this->manticore->isSuggestEnabled()) { + return response()->json([ + 'success' => false, + 'suggestions' => [], + 'error' => 'Suggest is disabled', + ]); + } + + if (empty($query)) { + return response()->json([ + 'success' => true, + 'suggestions' => [], + ]); + } + + $suggestions = $this->manticore->suggest($query, $index); + + // Get the best suggestion (highest doc count) + $bestSuggestion = null; + if (! empty($suggestions)) { + usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']); + $bestSuggestion = $suggestions[0]['suggest'] ?? null; + } + + return response()->json([ + 'success' => true, + 'query' => $query, + 'suggestions' => array_map(fn ($s) => $s['suggest'], $suggestions), + 'best' => $bestSuggestion, + 'details' => $suggestions, + ]); + } + + /** + * Combined endpoint for search assistance (autocomplete + suggest). + */ + public function searchAssist(Request $request): JsonResponse + { + $query = $request->input('q', ''); + $index = $request->input('index', 'releases_rt'); + + // Rate limiting + $key = 'searchassist:'.$request->ip(); + if (RateLimiter::tooManyAttempts($key, 60)) { + return response()->json([ + 'success' => false, + 'error' => 'Too many requests', + ], 429); + } + RateLimiter::hit($key, 60); + + $response = [ + 'success' => true, + 'query' => $query, + 'autocomplete' => [], + 'suggest' => null, + ]; + + // Get autocomplete suggestions + if ($this->manticore->isAutocompleteEnabled() && strlen($query) >= 2) { + $autocomplete = $this->manticore->autocomplete($query, $index); + $response['autocomplete'] = array_map(fn ($s) => $s['suggest'], $autocomplete); + } + + // Get spell suggestion if query is complete (user stopped typing) + if ($this->manticore->isSuggestEnabled() && ! empty($query)) { + $suggestions = $this->manticore->suggest($query, $index); + if (! empty($suggestions)) { + usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']); + $response['suggest'] = $suggestions[0]['suggest'] ?? null; + } + } + + return response()->json($response); + } +} + diff --git a/config/manticoresearch.php b/config/manticoresearch.php index 558d0062f..569484f3b 100644 --- a/config/manticoresearch.php +++ b/config/manticoresearch.php @@ -7,11 +7,24 @@ return [ |-------------------------------------------------------------------------- | | Configure the connection to your ManticoreSearch server. + | For high availability, you can configure multiple hosts. | */ 'host' => env('MANTICORESEARCH_HOST', '127.0.0.1'), 'port' => env('MANTICORESEARCH_PORT', 9308), + /* + |-------------------------------------------------------------------------- + | Multiple Connections (High Availability) + |-------------------------------------------------------------------------- + | + | Configure multiple ManticoreSearch nodes for failover. + | Set MANTICORESEARCH_HOSTS as comma-separated list: "host1:9308,host2:9308" + | + */ + 'hosts' => env('MANTICORESEARCH_HOSTS', ''), + 'retries' => env('MANTICORESEARCH_RETRIES', 2), + /* |-------------------------------------------------------------------------- | Index Names @@ -36,6 +49,35 @@ return [ 'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000), 'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5), + /* + |-------------------------------------------------------------------------- + | Autocomplete Settings + |-------------------------------------------------------------------------- + | + | Configure autocomplete/suggest behavior. + | + */ + 'autocomplete' => [ + 'enabled' => env('MANTICORESEARCH_AUTOCOMPLETE_ENABLED', true), + 'min_length' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH', 2), + 'max_results' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_MAX_RESULTS', 10), + 'fuzziness' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_FUZZINESS', 1), + 'cache_minutes' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_CACHE', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Suggest/Spell Correction Settings + |-------------------------------------------------------------------------- + | + | Configure "Did you mean?" spell correction. + | + */ + 'suggest' => [ + 'enabled' => env('MANTICORESEARCH_SUGGEST_ENABLED', true), + 'max_edits' => (int) env('MANTICORESEARCH_SUGGEST_MAX_EDITS', 4), + ], + /* |-------------------------------------------------------------------------- | Batch Processing diff --git a/resources/css/app.css b/resources/css/app.css index 56169dba4..16a108b03 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -355,3 +355,33 @@ } } +/* Preview Modal CSP-Safe Styles */ +.preview-modal-hidden, +.modal-hidden { + display: none; +} + +#previewModal, +.modal-z-index { + z-index: 9999; +} + +#previewModal.flex { + display: flex; +} + +/* Modal content scroll area */ +.modal-content-scroll { + max-height: calc(90vh - 80px); +} + +/* Search highlight styling */ +.search-highlight, +mark { + @apply bg-yellow-200 px-0.5 rounded; +} + +.dark .search-highlight, +.dark mark { + @apply bg-yellow-700; +} diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 35f960121..ff13acf5d 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -275,6 +275,7 @@ document.addEventListener('DOMContentLoaded', function() { initContentToggle(); initContentDelete(); initPasswordVisibilityToggles(); + initSearchAutocomplete(); // Initialize page-specific functionality from inline scripts initMyMovies(); @@ -5185,3 +5186,218 @@ function initUserListScrollSync() { } }); } + +/** + * Initialize search autocomplete functionality + * Handles both the main search page and header search inputs + */ +function initSearchAutocomplete() { + // Initialize header autocomplete (desktop and mobile) + initAutocompleteInput('header-search-input', 'header-autocomplete-dropdown', 'header-search-form', 'header-autocomplete-item'); + initAutocompleteInput('mobile-search-input', 'mobile-autocomplete-dropdown', 'mobile-search-form-el', 'header-autocomplete-item'); + + // Initialize main search page autocomplete + initAutocompleteInput('search', 'autocomplete-dropdown', 'searchForm', 'autocomplete-item'); + + // Initialize any dynamic autocomplete inputs (from Blade components) + document.querySelectorAll('[data-autocomplete-input]').forEach(function(input) { + const dropdownId = input.getAttribute('data-autocomplete-input'); + const formId = input.getAttribute('data-autocomplete-form'); + const inputId = input.id; + if (inputId && dropdownId) { + initAutocompleteInput(inputId, dropdownId, formId, 'autocomplete-suggestion'); + } + }); + + // Apply CSP-safe styles to modals (replaces inline styles) + initModalStyles(); +} + +/** + * Apply CSP-safe styles to modals that previously had inline styles + */ +function initModalStyles() { + // Preview modal + const previewModal = document.getElementById('previewModal'); + if (previewModal) { + previewModal.style.display = 'none'; + previewModal.style.zIndex = '9999'; + } + + // NFO modal + const nfoModal = document.getElementById('nfoModal'); + if (nfoModal) { + nfoModal.style.display = 'none'; + } + + // Image modal + const imageModal = document.getElementById('imageModal'); + if (imageModal) { + imageModal.style.display = 'none'; + } + + // MediaInfo modal + const mediainfoModal = document.getElementById('mediainfoModal'); + if (mediainfoModal) { + mediainfoModal.style.display = 'none'; + mediainfoModal.style.zIndex = '9999'; + } + + // Filelist modal + const filelistModal = document.getElementById('filelistModal'); + if (filelistModal) { + filelistModal.style.display = 'none'; + filelistModal.style.zIndex = '9999'; + } + + // MediaInfo content scroll + const mediainfoContent = document.getElementById('mediainfoContent'); + if (mediainfoContent) { + mediainfoContent.style.maxHeight = 'calc(90vh - 80px)'; + } + + // Filelist content scroll + const filelistContent = document.getElementById('filelistContent'); + if (filelistContent) { + filelistContent.style.maxHeight = 'calc(90vh - 80px)'; + } +} + +/** + * Initialize autocomplete for a specific input element + * @param {string} inputId - ID of the search input + * @param {string} dropdownId - ID of the dropdown container + * @param {string} formId - ID of the form to submit + * @param {string} itemClass - CSS class for dropdown items + */ +function initAutocompleteInput(inputId, dropdownId, formId, itemClass) { + const searchInput = document.getElementById(inputId); + const dropdown = document.getElementById(dropdownId); + const form = document.getElementById(formId); + + if (!searchInput || !dropdown) return; + + let debounceTimer; + let currentIndex = -1; + let suggestions = []; + + // Input handler with debounce + searchInput.addEventListener('input', function() { + clearTimeout(debounceTimer); + const query = this.value.trim(); + + if (query.length < 2) { + hideDropdown(); + return; + } + + debounceTimer = setTimeout(async () => { + try { + const response = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(query)}`); + const data = await response.json(); + + if (data.success && data.suggestions && data.suggestions.length > 0) { + suggestions = data.suggestions; + renderDropdown(suggestions, query); + } else { + hideDropdown(); + } + } catch (error) { + console.error('Autocomplete error:', error); + hideDropdown(); + } + }, 200); + }); + + // Keyboard navigation + searchInput.addEventListener('keydown', function(e) { + if (!dropdown.classList.contains('hidden')) { + const items = dropdown.querySelectorAll('.' + itemClass); + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + currentIndex = Math.min(currentIndex + 1, items.length - 1); + updateSelection(items); + break; + case 'ArrowUp': + e.preventDefault(); + currentIndex = Math.max(currentIndex - 1, 0); + updateSelection(items); + break; + case 'Enter': + if (currentIndex >= 0 && items[currentIndex]) { + e.preventDefault(); + searchInput.value = suggestions[currentIndex]; + hideDropdown(); + if (form) form.submit(); + } + break; + case 'Escape': + hideDropdown(); + break; + } + } + }); + + // Click outside to close + document.addEventListener('click', function(e) { + if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) { + hideDropdown(); + } + }); + + function renderDropdown(items, query) { + currentIndex = -1; + const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapeRegex(query)})`, 'gi'); + + const maxItems = itemClass === 'header-autocomplete-item' ? 8 : 10; + const sizeClass = itemClass === 'header-autocomplete-item' ? 'px-3 py-2 text-sm' : 'px-4 py-2'; + const iconSizeClass = itemClass === 'header-autocomplete-item' ? 'text-xs' : ''; + + dropdown.innerHTML = items.slice(0, maxItems).map((item, index) => { + const highlighted = item.replace(regex, '$1'); + return ` +
+ + ${highlighted} +
+ `; + }).join(''); + + // Add click handlers + dropdown.querySelectorAll('.' + itemClass).forEach((item, index) => { + item.addEventListener('click', function() { + searchInput.value = suggestions[index]; + hideDropdown(); + if (form) form.submit(); + }); + item.addEventListener('mouseenter', function() { + currentIndex = index; + updateSelection(dropdown.querySelectorAll('.' + itemClass)); + }); + }); + + dropdown.classList.remove('hidden'); + } + + function hideDropdown() { + dropdown.classList.add('hidden'); + dropdown.innerHTML = ''; + suggestions = []; + currentIndex = -1; + } + + function updateSelection(items) { + items.forEach((item, index) => { + if (index === currentIndex) { + item.classList.add('bg-blue-100', 'dark:bg-blue-900'); + } else { + item.classList.remove('bg-blue-100', 'dark:bg-blue-900'); + } + }); + } +} + diff --git a/resources/views/browse/index.blade.php b/resources/views/browse/index.blade.php index b8406e8c5..8cf7343ce 100644 --- a/resources/views/browse/index.blade.php +++ b/resources/views/browse/index.blade.php @@ -277,7 +277,7 @@ @endif -