*/ protected array $config; /** * @var array */ protected array $connection; public Client $manticoreSearch; public Search $search; /** @var array Tables already confirmed to exist (avoids repeated CREATE TABLE attempts). */ private array $verifiedTables = []; /** * Establishes a connection to ManticoreSearch HTTP port. * * @param array $config */ public function __construct(array $config = []) { $this->config = ! empty($config) ? $config : config('search.drivers.manticore'); $this->connection = ManticoreClientFactory::clientConfig($this->config); $this->manticoreSearch = ManticoreClientFactory::make($this->config); $this->search = new Search($this->manticoreSearch); } /** * Get the driver name. */ public function getDriverName(): string { return 'manticore'; } /** * Check if ManticoreSearch is available. */ public function isAvailable(): bool { $now = time(); if (self::$availabilityCache !== null && self::$availabilityCacheTime !== null && ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) { return self::$availabilityCache; } try { $status = $this->manticoreSearch->nodes()->status(); self::$availabilityCache = ! empty($status); self::$availabilityCacheTime = $now; return self::$availabilityCache; } catch (\Throwable $e) { self::$availabilityCache = false; self::$availabilityCacheTime = $now; if (config('app.debug')) { Log::debug('ManticoreSearch not available: '.$e->getMessage()); } return false; } } public function searchReleasePage(ReleaseSearchQuery $query): SearchPage { $startedAt = hrtime(true); $result = $this->searchReleasesFiltered($query->criteria(), $query->limit, $query->offset); return new SearchPage( ids: array_map(static fn (int|string $id): int => (int) $id, $result['ids']), total: (int) $result['total'], fuzzy: (bool) $result['fuzzy'], driver: $this->getDriverName(), available: (bool) ($result['available'] ?? true), durationMs: (hrtime(true) - $startedAt) / 1_000_000, lastSortValues: $result['last_sort'] ?? [], hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])), ); } /** * Check if autocomplete is enabled. */ public function isAutocompleteEnabled(): bool { return ($this->config['autocomplete']['enabled'] ?? true) === true; } /** * Check if suggest is enabled. */ public function isSuggestEnabled(): bool { return ($this->config['suggest']['enabled'] ?? true) === true; } /** * Check if fuzzy search is enabled. */ public function isFuzzyEnabled(): bool { return ($this->config['fuzzy']['enabled'] ?? true) === true; } /** * Get fuzzy search configuration. * * @return array */ public function getFuzzyConfig(): array { return $this->config['fuzzy'] ?? [ 'enabled' => true, 'max_distance' => 2, 'layouts' => 'us', ]; } /** * Get the releases index name. */ public function getReleasesIndex(): string { return $this->config['indexes']['releases'] ?? 'releases_rt'; } /** * Get the predb index name. */ public function getPredbIndex(): string { return $this->config['indexes']['predb'] ?? 'predb_rt'; } /** * Get the movies index name. */ public function getMoviesIndex(): string { return $this->config['indexes']['movies'] ?? 'movies_rt'; } /** * Get the TV shows index name. */ public function getTvShowsIndex(): string { return $this->config['indexes']['tvshows'] ?? 'tvshows_rt'; } /** * Insert release into ManticoreSearch releases_rt realtime index * * @param array $parameters */ public function insertRelease(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert release without ID'); return; } $releaseId = (int) $parameters['id']; if (! $this->replaceReleaseDocumentWithRetry($parameters)) { $this->recordReleaseIndexFailure($releaseId, 'insertRelease'); try { ReindexReleaseJob::dispatch($releaseId)->delay(now()->addSeconds(2)); } catch (\Throwable $e) { Log::error('ManticoreSearch: failed to queue ReindexReleaseJob', [ 'release_id' => $releaseId, 'error' => $e->getMessage(), ]); } } } /** * @param array $parameters */ private function replaceReleaseDocumentWithRetry(array $parameters): bool { $document = ReleaseSearchIndexDocument::normalize($parameters); unset($document['id']); $indexName = $this->config['indexes']['releases']; $releaseId = (int) $parameters['id']; for ($attempt = 0; $attempt < 2; $attempt++) { try { $this->manticoreSearch->table($indexName) ->replaceDocument($document, $releaseId); return true; } catch (\Throwable $e) { $isResponse = $e instanceof ResponseException; $isManticoreRuntime = $e instanceof RuntimeException; if (($isResponse || $isManticoreRuntime) && $attempt === 0) { usleep(100_000); continue; } if ($isResponse) { Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [ 'release_id' => $releaseId, 'index' => $indexName, ]); return false; } if ($isManticoreRuntime) { Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [ 'release_id' => $releaseId, ]); return false; } Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [ 'release_id' => $releaseId, 'trace' => $e->getTraceAsString(), ]); return false; } } return false; } private function recordReleaseIndexFailure(int $releaseId, string $phase): void { Cache::increment('search:index:failures:releases'); if (Cache::add('search:index:release_index_warn_lock', true, 60)) { Log::warning('ManticoreSearch: release indexing failure', [ 'release_id_sample' => $releaseId, 'phase' => $phase, 'failures_total' => (int) Cache::get('search:index:failures:releases', 0), ]); } } private function recordReleaseNotFoundForIndex(int|string $releaseID): void { Cache::increment('search:index:failures:release_not_found'); if (Cache::add('search:index:release_not_found_warn_lock', true, 60)) { Log::warning('ManticoreSearch: release not found when updating index', [ 'release_id' => $releaseID, 'not_found_total' => (int) Cache::get('search:index:failures:release_not_found', 0), ]); } } /** * Insert release into Manticore RT table. * * @param array $parameters */ public function insertPredb(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert predb without ID'); return; } try { $document = [ 'title' => $parameters['title'] ?? '', 'filename' => $parameters['filename'] ?? '', 'source' => $parameters['source'] ?? '', ]; $this->manticoreSearch->table($this->config['indexes']['predb']) ->replaceDocument($document, $parameters['id']); } catch (ResponseException $e) { Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (RuntimeException $e) { Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } } /** * Delete release from Manticore RT tables. * * @param int $id Release ID */ public function deleteRelease(int $id): void { if (empty($id)) { Log::warning('ManticoreSearch: Cannot delete release without ID'); return; } try { $this->manticoreSearch->table($this->config['indexes']['releases']) ->deleteDocument($id); } catch (ResponseException $e) { Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [ 'id' => $id, ]); } } /** * Delete a predb record from the index. * * @param int $id Predb ID */ public function deletePreDb(int $id): void { if (empty($id)) { Log::warning('ManticoreSearch: Cannot delete predb without ID'); return; } try { $this->manticoreSearch->table($this->config['indexes']['predb']) ->deleteDocument($id); } catch (ResponseException $e) { Log::error('ManticoreSearch deletePreDb error: '.$e->getMessage(), [ 'id' => $id, ]); } } /** * Bulk insert multiple releases into the index. * * @param array $releases Array of release data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertReleases(array $releases): array { if (empty($releases)) { return ['success' => 0, 'errors' => 0]; } $documents = []; foreach ($releases as $release) { if (empty($release['id'])) { continue; } $documents[] = array_merge( ['id' => $release['id']], ReleaseSearchIndexDocument::normalizeForBulk($release) ); } if (empty($documents)) { return ['success' => 0, 'errors' => 0]; } try { $this->manticoreSearch->table($this->config['indexes']['releases']) ->replaceDocuments($documents); return ['success' => count($documents), 'errors' => 0]; } catch (\Throwable $e) { Log::error('ManticoreSearch bulkInsertReleases error: '.$e->getMessage()); return ['success' => 0, 'errors' => count($documents)]; } } /** * Bulk insert multiple predb records into the index. * * @param array $predbRecords Array of predb data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertPredb(array $predbRecords): array { if (empty($predbRecords)) { return ['success' => 0, 'errors' => 0]; } $documents = []; foreach ($predbRecords as $predb) { if (empty($predb['id'])) { continue; } $documents[] = [ 'id' => $predb['id'], 'title' => (string) ($predb['title'] ?? ''), 'filename' => (string) ($predb['filename'] ?? ''), 'source' => (string) ($predb['source'] ?? ''), ]; } if (empty($documents)) { return ['success' => 0, 'errors' => 0]; } try { $this->manticoreSearch->table($this->config['indexes']['predb']) ->replaceDocuments($documents); return ['success' => count($documents), 'errors' => 0]; } catch (\Throwable $e) { Log::error('ManticoreSearch bulkInsertPredb error: '.$e->getMessage()); return ['success' => 0, 'errors' => count($documents)]; } } /** * Delete release from Manticore RT tables by GUID. * * @param array $identifiers ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)] */ public function deleteReleaseByGuid(array $identifiers): void { if (empty($identifiers['g'])) { Log::warning('ManticoreSearch: Cannot delete release without GUID'); return; } try { if ($identifiers['i'] === false || empty($identifiers['i'])) { $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); $identifiers['i'] = $release?->id; } if (! empty($identifiers['i'])) { $this->manticoreSearch->table($this->config['indexes']['releases']) ->deleteDocument($identifiers['i']); } else { Log::warning('ManticoreSearch: Could not find release ID for deletion', [ 'guid' => $identifiers['g'], ]); } } catch (ResponseException $e) { Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [ 'guid' => $identifiers['g'], 'id' => $identifiers['i'] ?? null, ]); } } /** * Escapes characters that are treated as special operators by the query language parser. * * This method escapes ALL special characters, including search operators. * Use prepareUserSearchQuery() instead for user-facing search queries * where operators like negation (!) should be preserved. */ public static function escapeString(string $string): string { if ($string === '*' || empty($string)) { return ''; } $from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-']; $to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-']; $string = str_replace($from, $to, $string); // Clean up trailing special characters $string = rtrim($string, '-!'); return trim($string); } /** * Prepares a user search query for ManticoreSearch, preserving search operators. * * Unlike escapeString() which escapes ALL special characters, this method * recognizes and preserves user-facing search operators: * * - Negation: !word or -word (excludes results containing "word") * - Phrase search: "exact phrase" * - Negated phrase: !"exact phrase" or -"exact phrase" * - OR operator: word1 | word2 * - Wildcards: word*, *word, *word* * - Grouping: (word1 | word2) -word3 * * Characters that are not useful as user operators are still escaped: * \, @, ~, &, /, $, =, ', [, ] */ public static function prepareUserSearchQuery(string $query): string { $query = trim($query); if ($query === '' || $query === '*') { return ''; } // Tokenize while preserving quoted phrases intact // Matches: optional negation prefix (! or -) + "quoted strings", OR non-whitespace sequences preg_match_all('/[-!]?"[^"]*"|\S+/', $query, $matches); $tokens = $matches[0]; if (empty($tokens)) { return ''; } // Characters that should always be escaped (not meaningful as user-facing search operators) // Includes " for unmatched quotes that appear in non-quoted tokens $escapeFrom = ['\\', '@', '~', '&', '/', '$', '=', "'", '[', ']', '"']; $escapeTo = ['\\\\', '\@', '\~', '\&', '\/', '\$', '\=', "\'", '\[', '\]', '\"']; $processed = []; foreach ($tokens as $token) { // Preserve OR operator if ($token === '|') { $processed[] = '|'; continue; } // Extract leading/trailing parentheses for grouping: (word) or ((word)) $leadingParens = ''; $trailingParens = ''; while (str_starts_with($token, '(')) { $leadingParens .= '('; $token = substr($token, 1); } while (str_ends_with($token, ')') && ! str_starts_with($token, '"')) { $trailingParens = ')'.$trailingParens; $token = substr($token, 0, -1); } if ($token === '') { // Only parens, no word content if ($leadingParens !== '' || $trailingParens !== '') { $processed[] = $leadingParens.$trailingParens; } continue; } // Detect negation prefix (! or -) at the start of a word $negation = ''; if (strlen($token) > 1 && ($token[0] === '!' || $token[0] === '-')) { $negation = $token[0]; $token = substr($token, 1); } // Handle quoted phrases: "exact phrase" (possibly with negation prefix) if (str_starts_with($token, '"') && str_ends_with($token, '"') && strlen($token) > 1) { $inner = substr($token, 1, -1); $inner = str_replace($escapeFrom, $escapeTo, $inner); // Escape ! and - inside phrases (they're literal text, not operators) $inner = str_replace(['!', '-'], ['\!', '\-'], $inner); $processed[] = $leadingParens.$negation.'"'.$inner.'"'.$trailingParens; continue; } // Detect wildcard prefix/suffix on non-quoted tokens $wildcardPrefix = ''; $wildcardSuffix = ''; if (str_starts_with($token, '*')) { $wildcardPrefix = '*'; $token = ltrim($token, '*'); } if (str_ends_with($token, '*')) { $wildcardSuffix = '*'; $token = rtrim($token, '*'); } // Escape non-operator special characters within the word $token = str_replace($escapeFrom, $escapeTo, $token); // Escape ! and - that appear INSIDE a word (not at the start as operators) // e.g., "spider-man" → "spider\-man", but "-circus" keeps the leading - $token = str_replace(['!', '-'], ['\!', '\-'], $token); if ($token !== '' || $wildcardPrefix !== '' || $wildcardSuffix !== '') { $processed[] = $leadingParens.$negation.$wildcardPrefix.$token.$wildcardSuffix.$trailingParens; } } $result = implode(' ', $processed); return trim($result); } private static function scopePreparedQueryToField(string $preparedQuery, string $fieldSelector = ''): string { $preparedQuery = trim($preparedQuery); if ($preparedQuery === '') { return ''; } $scopedQuery = '('.$preparedQuery.')'; if ($fieldSelector === '') { return $scopedQuery; } return $fieldSelector.' '.$scopedQuery; } /** * Check if a search query contains negation operators (! or - prefix on words). * * Used to prevent fuzzy fallback from reversing the user's negation intent. * For example, if the user searches "!harry", fuzzy should not strip the ! * and return "harry" results. * * @param array $phrases */ public static function queryHasNegation(array|string $phrases): bool { $values = []; if (is_string($phrases)) { $values[] = $phrases; } elseif (is_array($phrases)) { foreach ($phrases as $value) { if (is_string($value) && $value !== '' && $value !== '-1') { $values[] = $value; } } } foreach ($values as $value) { // Check if any token starts with ! or - (negation operator) if (preg_match('/(?:^|\s)[!-]\S/', $value)) { return true; } } return false; } public function updateRelease(int|string $releaseID): void { if (empty($releaseID)) { Log::warning('ManticoreSearch: Cannot update release without ID'); return; } try { $release = Release::query() ->where('releases.id', $releaseID) ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id') ->leftJoin('movieinfo as mi', 'releases.movieinfo_id', '=', 'mi.id') ->leftJoin('videos as v', 'releases.videos_id', '=', 'v.id') ->select([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', 'releases.size', 'releases.postdate', 'releases.adddate', 'releases.totalpart', 'releases.grabs', 'releases.passwordstatus', 'releases.groups_id', 'releases.nzbstatus', 'releases.haspreview', 'releases.imdbid', 'releases.videos_id', 'releases.movieinfo_id', DB::raw('IFNULL(mi.tmdbid, 0) AS tmdbid'), DB::raw('IFNULL(mi.traktid, 0) AS traktid'), DB::raw('IFNULL(v.tvdb, 0) AS tvdb'), DB::raw('IFNULL(v.tvmaze, 0) AS tvmaze'), DB::raw('IFNULL(v.tvrage, 0) AS tvrage'), DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), ]) ->groupBy([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', 'releases.size', 'releases.postdate', 'releases.adddate', 'releases.totalpart', 'releases.grabs', 'releases.passwordstatus', 'releases.groups_id', 'releases.nzbstatus', 'releases.haspreview', 'releases.imdbid', 'releases.videos_id', 'releases.movieinfo_id', 'mi.tmdbid', 'mi.traktid', 'v.tvdb', 'v.tvmaze', 'v.tvrage', ]) ->first(); if ($release !== null) { $this->insertRelease($release->toArray()); } else { Log::warning('ManticoreSearch: Release not found for update, removing from index', ['id' => $releaseID]); $this->recordReleaseNotFoundForIndex($releaseID); $this->deleteRelease((int) $releaseID); } } catch (\Throwable $e) { Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [ 'release_id' => $releaseID, ]); $this->recordReleaseIndexFailure((int) $releaseID, 'updateRelease_query'); } } /** * Update Manticore Predb index for given predb_id. * * @param array $parameters */ public function updatePreDb(array $parameters): void { if (empty($parameters)) { Log::warning('ManticoreSearch: Cannot update predb with empty parameters'); return; } $this->insertPredb($parameters); } /** * @param array $indexes */ public function truncateRTIndex(array $indexes = []): bool { if (empty($indexes)) { cli()->error('You need to provide index name to truncate'); return false; } $success = true; foreach ($indexes as $index) { if (! \in_array($index, $this->config['indexes'], true)) { cli()->error('Unsupported index: '.$index); $success = false; continue; } try { $this->manticoreSearch->table($index)->truncate(); cli()->info('Truncating index '.$index.' finished.'); } catch (ResponseException $e) { // Handle case where index doesn't exist - create it $message = $e->getMessage(); if (str_contains($message, 'does not exist') || $message === 'Invalid index') { cli()->info('Index '.$index.' does not exist, creating it...'); $this->createIndexIfNotExists($index); } else { cli()->error('Error truncating index '.$index.': '.$message); $success = false; } } catch (\Throwable $e) { // Also handle generic exceptions for non-existent tables $message = $e->getMessage(); if (str_contains($message, 'does not exist')) { cli()->info('Index '.$index.' does not exist, creating it...'); $this->createIndexIfNotExists($index); } else { cli()->error('Unexpected error truncating index '.$index.': '.$message); $success = false; } } } return $success; } /** * Truncate/clear an index (remove all documents). * Implements SearchServiceInterface::truncateIndex * * @param array|string $indexes Index name(s) to truncate */ public function truncateIndex(array|string $indexes): void { $indexArray = is_array($indexes) ? $indexes : [$indexes]; $this->truncateRTIndex($indexArray); } /** * Create index if it doesn't exist. * * Uses an in-memory set so the expensive CREATE TABLE round-trip is attempted * at most once per table per process lifetime. */ private function createIndexIfNotExists(string $index): void { if (isset($this->verifiedTables[$index])) { return; } try { $indices = $this->manticoreSearch->tables(); if ($index === 'releases_rt') { $indices->create([ 'index' => $index, 'body' => [ 'settings' => [ 'min_prefix_len' => 0, 'min_infix_len' => 2, ], 'columns' => [ 'name' => ['type' => 'text'], 'searchname' => ['type' => 'text'], 'fromname' => ['type' => 'text'], 'filename' => ['type' => 'text'], 'categories_id' => ['type' => 'integer'], 'imdbid' => ['type' => 'string'], 'tmdbid' => ['type' => 'integer'], 'traktid' => ['type' => 'integer'], 'tvdb' => ['type' => 'integer'], 'tvmaze' => ['type' => 'integer'], 'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'], 'size' => ['type' => 'bigint'], 'postdate_ts' => ['type' => 'bigint'], 'adddate_ts' => ['type' => 'bigint'], 'totalpart' => ['type' => 'integer'], 'grabs' => ['type' => 'integer'], 'passwordstatus' => ['type' => 'bigint'], 'groups_id' => ['type' => 'integer'], 'nzbstatus' => ['type' => 'integer'], 'haspreview' => ['type' => 'bigint'], ], ], ]); cli()->info('Created releases_rt index with external ID fields and infix search support'); } elseif ($index === 'predb_rt') { $indices->create([ 'index' => $index, 'body' => [ 'settings' => [ 'min_prefix_len' => 0, 'min_infix_len' => 2, ], 'columns' => [ 'title' => ['type' => 'text'], 'filename' => ['type' => 'text'], 'source' => ['type' => 'text'], ], ], ]); cli()->info('Created predb_rt index with infix search support'); } elseif ($index === 'movies_rt') { $indices->create([ 'index' => $index, 'body' => [ 'settings' => [ 'min_prefix_len' => 0, 'min_infix_len' => 2, ], 'columns' => [ 'imdbid' => ['type' => 'string'], 'tmdbid' => ['type' => 'integer'], 'traktid' => ['type' => 'integer'], 'title' => ['type' => 'text'], 'year' => ['type' => 'text'], 'genre' => ['type' => 'text'], 'actors' => ['type' => 'text'], 'director' => ['type' => 'text'], 'rating' => ['type' => 'text'], 'plot' => ['type' => 'text'], ], ], ]); cli()->info('Created movies_rt index with infix search support'); } elseif ($index === 'tvshows_rt') { $indices->create([ 'index' => $index, 'body' => [ 'settings' => [ 'min_prefix_len' => 0, 'min_infix_len' => 2, ], 'columns' => [ 'title' => ['type' => 'text'], 'tvdb' => ['type' => 'integer'], 'trakt' => ['type' => 'integer'], 'tvmaze' => ['type' => 'integer'], 'tvrage' => ['type' => 'integer'], 'imdb' => ['type' => 'string'], 'tmdb' => ['type' => 'integer'], 'started' => ['type' => 'text'], 'type' => ['type' => 'integer'], ], ], ]); cli()->info('Created tvshows_rt index with infix search support'); } else { foreach (SecondarySearchIndex::cases() as $secondary) { if ($index === $this->getSecondaryIndexName($secondary)) { $indices->create([ 'index' => $index, 'body' => [ 'settings' => [ 'min_prefix_len' => 0, 'min_infix_len' => 2, ], 'columns' => $secondary->manticoreColumns(), ], ]); cli()->info("Created {$index} secondary metadata index"); break; } } } } catch (\Throwable $e) { if (! str_contains($e->getMessage(), 'already exists')) { cli()->error('Error creating index '.$index.': '.$e->getMessage()); } } $this->verifiedTables[$index] = true; } /** * Optimize the RT indices. */ public function optimizeRTIndex(): bool { $success = true; foreach ($this->config['indexes'] as $index) { try { $this->manticoreSearch->table($index)->flush(); $this->manticoreSearch->table($index)->optimize(); Log::info("Successfully optimized index: {$index}"); } catch (ResponseException $e) { Log::error('Failed to optimize index '.$index.': '.$e->getMessage()); $success = false; } catch (\Throwable $e) { Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage()); $success = false; } } return $success; } /** * Optimize index for better search performance. * Implements SearchServiceInterface::optimizeIndex */ public function optimizeIndex(): void { $this->optimizeRTIndex(); } /** * Search releases index. * * @param array|string $phrases Search phrases - can be a string, indexed array of terms, or associative array with field names * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleases(array|string $phrases, int $limit = 1000): array { if (is_string($phrases)) { // Simple string search - search in searchname field $searchArray = ['searchname' => $phrases]; } else { // Check if it's an associative array (has string keys like 'searchname') $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; if ($isAssociative) { // Already has field names as keys $searchArray = $phrases; } else { // Indexed array - combine values and search in searchname $searchArray = ['searchname' => implode(' ', $phrases)]; } } $result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray, $limit); return ! empty($result) ? ($result['id'] ?? []) : []; } /** * Search releases with fuzzy fallback. * * If exact search returns no results and fuzzy is enabled, this method * will automatically try a fuzzy search as a fallback. * * @param array|string $phrases Search phrases * @param int $limit Maximum number of results * @param bool $forceFuzzy Force fuzzy search regardless of exact results * @return array Array with 'ids' (release IDs) and 'fuzzy' (bool indicating if fuzzy was used) */ public function searchReleasesWithFuzzy(array|string $phrases, int $limit = 1000, bool $forceFuzzy = false): array { // First try exact search unless forcing fuzzy if (! $forceFuzzy) { $exactResults = $this->searchReleases($phrases, $limit); if (! empty($exactResults)) { return [ 'ids' => $exactResults, 'fuzzy' => false, ]; } } // Skip fuzzy fallback when the query contains negation operators (! or -). // Fuzzy search strips all special characters, which would reverse the user's // intent: "!harry" would become "harry" and return exactly what they wanted to exclude. if (self::queryHasNegation($phrases)) { if (config('app.debug')) { Log::debug('ManticoreSearch::searchReleasesWithFuzzy skipping fuzzy - query contains negation operators', [ 'phrases' => $phrases, ]); } return [ 'ids' => [], 'fuzzy' => false, ]; } // If exact search returned nothing (or forcing fuzzy) and fuzzy is enabled, try fuzzy search if ($this->isFuzzyEnabled()) { $fuzzyResults = $this->fuzzySearchReleases($phrases, $limit); if (! empty($fuzzyResults)) { return [ 'ids' => $fuzzyResults, 'fuzzy' => true, ]; } } return [ 'ids' => [], 'fuzzy' => false, ]; } /** * Perform fuzzy search on releases index. * * Uses Manticore's native fuzzy search with Levenshtein distance algorithm. * * @param array|string $phrases Search phrases * @param int $limit Maximum number of results * @return array Array of release IDs */ public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array { if (! $this->isFuzzyEnabled()) { return []; } if (is_string($phrases)) { $searchArray = ['searchname' => $phrases]; } else { $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; if ($isAssociative) { $searchArray = $phrases; } else { $searchArray = ['searchname' => implode(' ', $phrases)]; } } $result = $this->fuzzySearchIndexes($this->getReleasesIndex(), $searchArray, $limit); return ! empty($result) ? ($result['id'] ?? []) : []; } /** * Perform fuzzy search on an index using Manticore's native fuzzy search. * * Uses Levenshtein distance algorithm to find matches with typo tolerance. * Supports: * - Missing characters: "laptp" → "laptop" * - Extra characters: "laptopp" → "laptop" * - Transposed characters: "lpatop" → "laptop" * - Wrong characters: "laptip" → "laptop" * * @param string $index Index to search * @param array $searchArray Associative array of field => value to search * @param int $limit Maximum number of results * @return array Array with 'id' and 'data' keys */ public function fuzzySearchIndexes(string $index, array $searchArray, int $limit = 1000): array { if (empty($index) || empty($searchArray)) { return []; } $normalizedLimit = $this->normalizeSearchLimit($limit); $fuzzyConfig = $this->getFuzzyConfig(); $distance = $fuzzyConfig['max_distance'] ?? 2; // Create cache key for fuzzy search results $cacheKey = 'manticore:fuzzy:'.md5(serialize([ 'index' => $index, 'array' => $searchArray, 'limit' => $normalizedLimit, 'distance' => $distance, ])); $cached = Cache::get($cacheKey); if ($cached !== null) { if (config('app.debug')) { Log::debug('ManticoreSearch::fuzzySearchIndexes returning cached result', [ 'cacheKey' => $cacheKey, ]); } return $cached; } // For fuzzy search, we need to use a simple query string without the @@relaxed @field syntax // The fuzzy option only works with plain query_string queries $searchTerms = []; foreach ($searchArray as $field => $value) { if (! empty($value)) { // Clean value for fuzzy search - remove special characters but keep words $cleanValue = preg_replace('/[^\w\s]/', ' ', $value); $cleanValue = preg_replace('/\s+/', ' ', trim($cleanValue)); if (! empty($cleanValue)) { $searchTerms[] = $cleanValue; } } } if (empty($searchTerms)) { return []; } $searchExpr = implode(' ', $searchTerms); if (config('app.debug')) { Log::debug('ManticoreSearch::fuzzySearchIndexes query', [ 'index' => $index, 'searchExpr' => $searchExpr, 'fuzzy' => true, 'distance' => $distance, ]); } try { // Use Manticore's native fuzzy search with Levenshtein distance // Important: use search() with plain query string for fuzzy to work // Note: Keep options minimal - layouts, stripBadUtf8, and sort can interfere with fuzzy $query = (new Search($this->manticoreSearch)) ->setTable($index) ->search($searchExpr) ->option('fuzzy', true) ->option('distance', $distance) ->limit($normalizedLimit); $results = $query->get(); } catch (ResponseException $e) { $message = $e->getMessage(); // Check if fuzzy search failed due to missing min_infix_len // This happens when index was created without proper settings if (str_contains($message, 'min_infix_len')) { Log::warning('ManticoreSearch fuzzySearchIndexes: Fuzzy search unavailable - index missing min_infix_len setting. Please recreate the index with: php artisan manticore:create-indexes --drop', [ 'index' => $index, ]); // Fall back to regular search without fuzzy return $this->searchIndexes($index, '', [], $searchArray, $normalizedLimit); } Log::error('ManticoreSearch fuzzySearchIndexes ResponseException: '.$message, [ 'index' => $index, 'searchArray' => $searchArray, ]); return []; } catch (RuntimeException $e) { Log::error('ManticoreSearch fuzzySearchIndexes RuntimeException: '.$e->getMessage(), [ 'index' => $index, ]); return []; } catch (\Throwable $e) { Log::error('ManticoreSearch fuzzySearchIndexes unexpected error: '.$e->getMessage(), [ 'index' => $index, ]); return []; } $resultIds = []; $resultData = []; foreach ($results as $doc) { $resultIds[] = $doc->getId(); $resultData[] = $doc->getData(); } $result = [ 'id' => $resultIds, 'data' => $resultData, ]; if (config('app.debug')) { Log::debug('ManticoreSearch::fuzzySearchIndexes results', [ 'index' => $index, 'total_results' => count($resultIds), ]); } // Cache fuzzy results for 5 minutes if (! empty($resultIds)) { Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $result; } /** * Search predb index. * * @param array|string $searchTerm Search term(s) * @return array Array of predb records */ public function searchPredb(array|string $searchTerm): array { $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; $result = $this->searchIndexes($this->getPredbIndex(), $searchString, ['title', 'filename'], []); // @phpstan-ignore argument.type return $result['data'] ?? []; } /** * @param array $column * @param array $searchArray * @return array */ public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = [], int $limit = 1000): array { if (empty($rt_index)) { Log::warning('ManticoreSearch: Index name is required for search'); return []; } $normalizedLimit = $this->normalizeSearchLimit($limit); if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes called', [ 'rt_index' => $rt_index, 'searchString' => $searchString, 'column' => $column, 'searchArray' => $searchArray, 'limit' => $normalizedLimit, ]); } // Create cache key for search results $cacheKey = md5(serialize([ 'index' => $rt_index, 'search' => $searchString, 'columns' => $column, 'array' => $searchArray, 'limit' => $normalizedLimit, ])); $cached = Cache::get($cacheKey); if ($cached !== null) { if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes returning cached result', [ 'cacheKey' => $cacheKey, 'cached_ids_count' => count($cached['id'] ?? []), ]); } return $cached; } // Build query string once so we can retry if needed // Use prepareUserSearchQuery() to preserve search operators (!, -, "", |, *) $searchExpr = null; if (! empty($searchArray)) { $terms = []; foreach ($searchArray as $key => $value) { if (! empty($value)) { $preparedValue = self::prepareUserSearchQuery($value); if (! empty($preparedValue)) { $terms[] = '@@relaxed '.self::scopePreparedQueryToField($preparedValue, '@'.$key); } } } if (! empty($terms)) { $searchExpr = implode(' ', $terms); } else { if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray'); } return []; } } elseif (! empty($searchString)) { $preparedSearch = self::prepareUserSearchQuery($searchString); if (empty($preparedSearch)) { if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes preparedSearch is empty'); } return []; } $searchColumns = ''; if (! empty($column)) { if (count($column) > 1) { $searchColumns = '@('.implode(',', $column).')'; } else { $searchColumns = '@'.$column[0]; // @phpstan-ignore offsetAccess.notFound } } $searchExpr = '@@relaxed '.self::scopePreparedQueryToField($preparedSearch, $searchColumns); } else { return []; } // Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error $avoidSortForIndex = ($rt_index === 'predb_rt'); if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes executing query', [ 'rt_index' => $rt_index, 'searchExpr' => $searchExpr, ]); } try { // Use a fresh Search instance for every query to avoid parameter accumulation across calls $query = (new Search($this->manticoreSearch)) ->setTable($rt_index) ->option('ranker', 'sph04') ->maxMatches($normalizedLimit) ->limit($normalizedLimit) ->stripBadUtf8(true) ->search($searchExpr); if (! $avoidSortForIndex) { $query->sort('id', 'desc'); } $results = $query->get(); } catch (ResponseException $e) { // If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) { try { $query = (new Search($this->manticoreSearch)) ->setTable($rt_index) ->option('ranker', 'sph04') ->maxMatches($normalizedLimit) ->limit($normalizedLimit) ->stripBadUtf8(true) ->search($searchExpr); $results = $query->get(); Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [ 'index' => $rt_index, ]); } catch (ResponseException $e2) { Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } } else { Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } } catch (RuntimeException $e) { Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } catch (\Throwable $e) { Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } // Parse results and cache $resultIds = []; $resultData = []; foreach ($results as $doc) { $resultIds[] = $doc->getId(); $resultData[] = $doc->getData(); } $result = [ 'id' => $resultIds, 'data' => $resultData, ]; // Only cache non-empty results to avoid caching temporary failures or empty index states if (! empty($resultIds)) { Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $result; } private function normalizeSearchLimit(int $limit): int { $maxMatches = max(1, (int) ($this->config['max_matches'] ?? 10000)); return max(1, min($limit, $maxMatches)); } /** * 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'] ?? [ 'enabled' => true, 'min_length' => 2, 'max_results' => 10, 'cache_minutes' => 10, ]; if (! ($autocompleteConfig['enabled'] ?? true)) { return []; } $query = trim($query); $minLength = $autocompleteConfig['min_length'] ?? 2; if (strlen($query) < $minLength) { return []; } $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt'); $cacheKey = 'manticore:autocomplete:'.md5($index.$query); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } $suggestions = []; $maxResults = $autocompleteConfig['max_results'] ?? 10; 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) ->sort('id', 'desc') ->limit($maxResults * 3) ->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) >= $maxResults) { break; } } } catch (\Throwable $e) { if (config('app.debug')) { Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage()); } } if (! empty($suggestions)) { $cacheMinutes = (int) ($autocompleteConfig['cache_minutes'] ?? 10); Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes)); } 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?"). * * @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'] ?? [ 'enabled' => true, 'max_edits' => 4, ]; if (! ($suggestConfig['enabled'] ?? true)) { return []; } $query = trim($query); if (empty($query)) { return []; } // Don't suggest spelling corrections for queries with negation operators. // Suggesting "harry" for "!harry" would be misleading since the user // intentionally wants to exclude that term. if (self::queryHasNegation($query)) { return []; } $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt'); $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'] ?? 4, ], ], ]); 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($this->config['cache_minutes'] ?? 5)); } 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 { $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::debug('ManticoreSearch suggest fallback error: '.$e->getMessage()); } return []; } } /** * Insert a movie into the movies search index. * * @param array $parameters Movie data */ public function insertMovie(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert movie without ID'); return; } try { $document = [ 'imdbid' => (string) ($parameters['imdbid'] ?? ''), 'tmdbid' => (int) ($parameters['tmdbid'] ?? 0), 'traktid' => (int) ($parameters['traktid'] ?? 0), 'title' => (string) ($parameters['title'] ?? ''), 'year' => (string) ($parameters['year'] ?? ''), 'genre' => (string) ($parameters['genre'] ?? ''), 'actors' => (string) ($parameters['actors'] ?? ''), 'director' => (string) ($parameters['director'] ?? ''), 'rating' => (string) ($parameters['rating'] ?? ''), 'plot' => (string) ($parameters['plot'] ?? ''), ]; $this->manticoreSearch->table($this->getMoviesIndex()) ->replaceDocument($document, $parameters['id']); } catch (ResponseException $e) { Log::error('ManticoreSearch insertMovie ResponseException: '.$e->getMessage(), [ 'movie_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertMovie unexpected error: '.$e->getMessage(), [ 'movie_id' => $parameters['id'], ]); } } /** * Update a movie in the search index. * * @param int $movieId Movie ID */ public function updateMovie(int $movieId): void { if (empty($movieId)) { Log::warning('ManticoreSearch: Cannot update movie without ID'); return; } try { $movie = MovieInfo::find($movieId); if ($movie !== null) { $this->insertMovie($movie->toArray()); } else { Log::warning('ManticoreSearch: Movie not found for update', ['id' => $movieId]); } } catch (\Throwable $e) { Log::error('ManticoreSearch updateMovie error: '.$e->getMessage(), [ 'movie_id' => $movieId, ]); } } /** * Delete a movie from the search index. * * @param int $id Movie ID */ public function deleteMovie(int $id): void { if (empty($id)) { Log::warning('ManticoreSearch: Cannot delete movie without ID'); return; } try { $this->manticoreSearch->table($this->getMoviesIndex()) ->deleteDocument($id); } catch (ResponseException $e) { Log::error('ManticoreSearch deleteMovie error: '.$e->getMessage(), [ 'id' => $id, ]); } } /** * Bulk insert multiple movies into the index. * * @param array $movies Array of movie data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertMovies(array $movies): array { if (empty($movies)) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $documents = []; foreach ($movies as $movie) { if (empty($movie['id'])) { $errors++; continue; } $documents[] = [ 'id' => $movie['id'], 'imdbid' => (string) ($movie['imdbid'] ?? ''), 'tmdbid' => (int) ($movie['tmdbid'] ?? 0), 'traktid' => (int) ($movie['traktid'] ?? 0), 'title' => (string) ($movie['title'] ?? ''), 'year' => (string) ($movie['year'] ?? ''), 'genre' => (string) ($movie['genre'] ?? ''), 'actors' => (string) ($movie['actors'] ?? ''), 'director' => (string) ($movie['director'] ?? ''), 'rating' => (string) ($movie['rating'] ?? ''), 'plot' => (string) ($movie['plot'] ?? ''), ]; } if (! empty($documents)) { try { $this->manticoreSearch->table($this->getMoviesIndex()) ->replaceDocuments($documents); $success = count($documents); } catch (\Throwable $e) { Log::error('ManticoreSearch bulkInsertMovies error: '.$e->getMessage()); $errors += count($documents); } } return ['success' => $success, 'errors' => $errors]; } /** * Search the movies index. * * @param array|string $searchTerm Search term(s) * @param int $limit Maximum number of results * @return array Array with 'id' (movie IDs) and 'data' (movie data) */ public function searchMovies(array|string $searchTerm, int $limit = 1000): array { $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; return $this->searchIndexes($this->getMoviesIndex(), $searchString, ['title', 'actors', 'director'], []); // @phpstan-ignore argument.type } /** * {@inheritdoc} */ public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array { $allowed = ['title' => true, 'director' => true, 'actors' => true, 'genre' => true]; $filtered = []; foreach ($fieldTerms as $key => $value) { $k = (string) $key; if (isset($allowed[$k]) && is_string($value) && trim($value) !== '') { $filtered[$k] = trim($value); } } if ($filtered === [] || ! $this->isAvailable()) { return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; } $raw = $this->searchIndexes($this->getMoviesIndex(), null, [], $filtered, $limit); $imdbids = []; $movieinfoIds = []; $data = $raw['data'] ?? []; foreach (($raw['id'] ?? []) as $i => $mid) { $movieinfoIds[] = (int) $mid; $row = $data[$i] ?? []; $imdb = (string) ($row['imdbid'] ?? ''); if ($imdb !== '') { $imdbids[] = $imdb; } } return [ 'imdbids' => array_values(array_unique($imdbids)), 'movieinfo_ids' => $movieinfoIds, 'data' => $data, ]; } /** * Search movies by external ID (IMDB, TMDB, Trakt). * * @param string $field Field name (imdbid, tmdbid, traktid) * @param int|string $value The external ID value * @return array|null Movie data or null if not found */ public function searchMovieByExternalId(string $field, int|string $value): ?array { if (empty($value)) { return null; } return $this->searchMovieByExternalIds([$field => $value]); } /** * @param array $externalIds * @return array|null */ public function searchMovieByExternalIds(array $externalIds): ?array { $allowedFields = ['imdbid', 'tmdbid', 'traktid']; $normalized = []; foreach ($allowedFields as $field) { $value = $externalIds[$field] ?? null; if (empty($value)) { continue; } $normalized[$field] = $field === 'imdbid' ? (string) $value : (int) $value; } if ($normalized === []) { return null; } $cacheKey = 'manticore:movie:any:'.md5(serialize($normalized)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { foreach ($normalized as $field => $value) { $query = (new Search($this->manticoreSearch)) ->setTable($this->getMoviesIndex()) ->filter($field, '=', $value) ->limit(1); $results = $query->get(); foreach ($results as $doc) { $data = $doc->getData(); $data['id'] = $doc->getId(); Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); return $data; } } } catch (\Throwable $e) { Log::error('ManticoreSearch searchMovieByExternalIds error: '.$e->getMessage(), [ 'externalIds' => $externalIds, ]); } return null; } /** * Insert a TV show into the tvshows search index. * * @param array $parameters TV show data */ public function insertTvShow(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert TV show without ID'); return; } try { $document = [ 'title' => (string) ($parameters['title'] ?? ''), 'tvdb' => (int) ($parameters['tvdb'] ?? 0), 'trakt' => (int) ($parameters['trakt'] ?? 0), 'tvmaze' => (int) ($parameters['tvmaze'] ?? 0), 'tvrage' => (int) ($parameters['tvrage'] ?? 0), 'imdb' => (string) ($parameters['imdb'] ?? ''), 'tmdb' => (int) ($parameters['tmdb'] ?? 0), 'started' => (string) ($parameters['started'] ?? ''), 'type' => (int) ($parameters['type'] ?? 0), ]; $this->manticoreSearch->table($this->getTvShowsIndex()) ->replaceDocument($document, $parameters['id']); } catch (ResponseException $e) { Log::error('ManticoreSearch insertTvShow ResponseException: '.$e->getMessage(), [ 'tvshow_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertTvShow unexpected error: '.$e->getMessage(), [ 'tvshow_id' => $parameters['id'], ]); } } /** * Update a TV show in the search index. * * @param int $videoId Video/TV show ID */ public function updateTvShow(int $videoId): void { if (empty($videoId)) { Log::warning('ManticoreSearch: Cannot update TV show without ID'); return; } try { $video = Video::find($videoId); if ($video !== null) { $this->insertTvShow($video->toArray()); } else { Log::warning('ManticoreSearch: TV show not found for update', ['id' => $videoId]); } } catch (\Throwable $e) { Log::error('ManticoreSearch updateTvShow error: '.$e->getMessage(), [ 'tvshow_id' => $videoId, ]); } } /** * Delete a TV show from the search index. * * @param int $id TV show ID */ public function deleteTvShow(int $id): void { if (empty($id)) { Log::warning('ManticoreSearch: Cannot delete TV show without ID'); return; } try { $this->manticoreSearch->table($this->getTvShowsIndex()) ->deleteDocument($id); } catch (ResponseException $e) { Log::error('ManticoreSearch deleteTvShow error: '.$e->getMessage(), [ 'id' => $id, ]); } } /** * Bulk insert multiple TV shows into the index. * * @param array $tvShows Array of TV show data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertTvShows(array $tvShows): array { if (empty($tvShows)) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $documents = []; foreach ($tvShows as $tvShow) { if (empty($tvShow['id'])) { $errors++; continue; } $documents[] = [ 'id' => $tvShow['id'], 'title' => (string) ($tvShow['title'] ?? ''), 'tvdb' => (int) ($tvShow['tvdb'] ?? 0), 'trakt' => (int) ($tvShow['trakt'] ?? 0), 'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0), 'tvrage' => (int) ($tvShow['tvrage'] ?? 0), 'imdb' => (string) ($tvShow['imdb'] ?? ''), 'tmdb' => (int) ($tvShow['tmdb'] ?? 0), 'started' => (string) ($tvShow['started'] ?? ''), 'type' => (int) ($tvShow['type'] ?? 0), ]; } if (! empty($documents)) { try { $this->manticoreSearch->table($this->getTvShowsIndex()) ->replaceDocuments($documents); $success = count($documents); } catch (\Throwable $e) { Log::error('ManticoreSearch bulkInsertTvShows error: '.$e->getMessage()); $errors += count($documents); } } return ['success' => $success, 'errors' => $errors]; } /** * Search the TV shows index. * * @param array|string $searchTerm Search term(s) * @param int $limit Maximum number of results * @return array Array with 'id' (TV show IDs) and 'data' (TV show data) */ public function searchTvShows(array|string $searchTerm, int $limit = 1000): array { $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; return $this->searchIndexes($this->getTvShowsIndex(), $searchString, ['title'], []); // @phpstan-ignore argument.type } /** * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). * * @param string $field Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb) * @param int|string $value The external ID value * @return array|null TV show data or null if not found */ public function searchTvShowByExternalId(string $field, int|string $value): ?array { if (empty($value)) { return null; } return $this->searchTvShowByExternalIds([$field => $value]); } /** * @param array $externalIds * @return array|null */ public function searchTvShowByExternalIds(array $externalIds): ?array { $allowedFields = ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb']; $normalized = []; foreach ($allowedFields as $field) { $value = $externalIds[$field] ?? null; if (empty($value)) { continue; } $normalized[$field] = $field === 'imdb' ? (string) $value : (int) $value; } if ($normalized === []) { return null; } $cacheKey = 'manticore:tvshow:any:'.md5(serialize($normalized)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { foreach ($normalized as $field => $value) { $query = (new Search($this->manticoreSearch)) ->setTable($this->getTvShowsIndex()) ->filter($field, '=', $value) ->limit(1); $results = $query->get(); foreach ($results as $doc) { $data = $doc->getData(); $data['id'] = $doc->getId(); Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); return $data; } } } catch (\Throwable $e) { Log::error('ManticoreSearch searchTvShowByExternalIds error: '.$e->getMessage(), [ 'externalIds' => $externalIds, ]); } return null; } /** * Search releases by external media IDs. * Used to find releases associated with a specific movie or TV show. * * @param array $externalIds Associative array of external IDs * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array { return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit); } /** * @param array> $externalIdSets * @return array */ public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array { if ($externalIdSets === []) { return []; } $normalizedLimit = min($limit, 10000); $cacheKey = 'manticore:releases:extid_sets:'.md5(serialize([$externalIdSets, $normalizedLimit])); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } $queries = []; foreach ($externalIdSets as $externalIds) { foreach ($externalIds as $field => $value) { if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'], true)) { continue; } $typedValue = $field === 'imdbid' ? (string) $value : (int) $value; $key = $field.':'.$typedValue; $queries[$key] = [ 'field' => $field, 'value' => $typedValue, ]; } } if ($queries === []) { return []; } $resultIds = []; $seen = []; try { foreach ($queries as $queryData) { $query = (new Search($this->manticoreSearch)) ->setTable($this->getReleasesIndex()) ->filter($queryData['field'], '=', $queryData['value']) ->limit($normalizedLimit); $results = $query->get(); foreach ($results as $doc) { $docId = (int) $doc->getId(); if (isset($seen[$docId])) { continue; } $seen[$docId] = true; $resultIds[] = $docId; if (count($resultIds) >= $normalizedLimit) { break 2; } } } } catch (\Throwable $e) { Log::error('ManticoreSearch searchReleasesByMultipleExternalIds error: '.$e->getMessage(), [ 'externalIdSets' => $externalIdSets, ]); return []; } if ($resultIds !== []) { Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $resultIds; } /** * Search releases by category ID using the search index. * This provides a fast way to get release IDs for a specific category without hitting the database. * * @param array $categoryIds Array of category IDs to filter by * @param int $limit Maximum number of results * @return array */ public function searchReleasesByCategory(array $categoryIds, int $limit = 1000): array { if (empty($categoryIds)) { return []; } // Filter out invalid category IDs (-1 means "all categories") $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0); if (empty($validCategoryIds)) { return []; } $cacheKey = 'manticore:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $query = (new Search($this->manticoreSearch)) ->setTable($this->getReleasesIndex()) ->limit(min($limit, 10000)); // Use IN filter for multiple category IDs if (count($validCategoryIds) === 1) { $query->filter('categories_id', '=', (int) $validCategoryIds[0]); // @phpstan-ignore offsetAccess.notFound } else { $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds)); } $results = $query->get(); $resultIds = []; foreach ($results as $doc) { $resultIds[] = $doc->getId(); } if (! empty($resultIds)) { Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $resultIds; // @phpstan-ignore return.type } catch (\Throwable $e) { Log::error('ManticoreSearch searchReleasesByCategory error: '.$e->getMessage(), [ 'categoryIds' => $categoryIds, ]); } return []; } /** * Combined search: text search with category filtering. * First searches by text, then filters by category IDs using the search index. * * @param string $searchTerm Search text * @param array $categoryIds Array of category IDs to filter by (empty for all categories) * @param int $limit Maximum number of results * @return array */ public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array { if (empty($searchTerm)) { // If no search term, just filter by category return $this->searchReleasesByCategory($categoryIds, $limit); } // Filter out invalid category IDs $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0); $cacheKey = 'manticore:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $preparedSearch = self::prepareUserSearchQuery($searchTerm); if (empty($preparedSearch)) { return $this->searchReleasesByCategory($categoryIds, $limit); } $searchExpr = '@@relaxed '.self::scopePreparedQueryToField($preparedSearch, '@searchname'); $query = (new Search($this->manticoreSearch)) ->setTable($this->getReleasesIndex()) ->search($searchExpr) ->option('ranker', 'sph04') ->stripBadUtf8(true) ->limit(min($limit, 10000)); // Add category filter if provided if (! empty($validCategoryIds)) { if (count($validCategoryIds) === 1) { $query->filter('categories_id', '=', (int) $validCategoryIds[0]); // @phpstan-ignore offsetAccess.notFound } else { $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds)); } } $results = $query->get(); $resultIds = []; foreach ($results as $doc) { $resultIds[] = $doc->getId(); } if (! empty($resultIds)) { Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $resultIds; // @phpstan-ignore return.type } catch (\Throwable $e) { Log::error('ManticoreSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [ 'searchTerm' => $searchTerm, 'categoryIds' => $categoryIds, ]); } return []; } /** * {@inheritdoc} */ public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array { if (! $this->isAvailable()) { return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false]; } $phrases = $criteria['phrases'] ?? null; $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); $execute = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { $query = (new Search($this->manticoreSearch)) ->setTable($this->getReleasesIndex()) ->stripBadUtf8(true); if ($hasText) { if ($useFuzzy && $this->isFuzzyEnabled()) { if (self::queryHasNegation($phrases)) { return ['ids' => [], 'total' => 0, 'fuzzy' => false]; } $searchString = $this->phrasesToFuzzyString($phrases); if ($searchString === '') { return ['ids' => [], 'total' => 0, 'fuzzy' => false]; } $query->search($searchString) ->option('fuzzy', true) ->option('distance', (int) ($this->getFuzzyConfig()['max_distance'] ?? 2)); } else { $searchArray = $this->phrasesToSearchArray($phrases); $terms = []; foreach ($searchArray as $key => $value) { if ($value === '' || $value === null) { continue; } $prepared = self::prepareUserSearchQuery((string) $value); if ($prepared !== '') { $terms[] = '@@relaxed '.self::scopePreparedQueryToField($prepared, '@'.$key); } } if ($terms === []) { return ['ids' => [], 'total' => 0, 'fuzzy' => false]; } $query->search(implode(' ', $terms))->option('ranker', 'sph04'); } } $this->applyManticoreReleaseIndexFilters($query, $criteria); $sortField = (string) ($criteria['sort_field'] ?? 'postdate_ts'); $sortDir = strtolower((string) ($criteria['sort_dir'] ?? 'desc')); $allowedSort = ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id']; if (! in_array($sortField, $allowedSort, true)) { $sortField = 'postdate_ts'; } $order = $sortDir === 'asc' ? 'asc' : 'desc'; $query->sort($sortField, $order); if ($sortField !== 'id') { // Stabilize ordering across pages when primary sort values are equal. $query->sort('id', $order); } $maxNeed = max(1000, $offset + $limit + 100); $query->maxMatches(min($maxNeed, (int) ($this->config['max_matches'] ?? 10000))); $query->limit(max(1, $limit)); if ($offset > 0) { $query->offset($offset); } try { $results = $query->get(); } catch (\Throwable $e) { Log::error('ManticoreSearch searchReleasesFiltered error: '.$e->getMessage(), [ 'criteria' => $criteria, ]); return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false]; } $ids = []; foreach ($results as $doc) { $ids[] = $doc->getId(); } return [ 'ids' => $ids, 'total' => (int) $results->getTotal(), 'fuzzy' => $useFuzzy, 'available' => true, // Manticore uses the compatible offset cursor until the PHP client // supports a compound search-after predicate without raw SQL. 'last_sort' => [], 'has_more' => $offset + count($ids) < (int) $results->getTotal(), ]; }; if ($hasText) { $first = $execute(false); if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled() || self::queryHasNegation($phrases)) { return $first; } return $execute(true); } return $execute(false); } /** * @param array|string $phrases */ private function phrasesToSearchArray(array|string $phrases): array { if (is_string($phrases)) { return ['searchname' => $phrases]; } $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; if ($isAssociative) { return $phrases; } return ['searchname' => implode(' ', $phrases)]; } /** * @param array|string $phrases */ private function phrasesToFuzzyString(array|string $phrases): string { $searchArray = $this->phrasesToSearchArray($phrases); $searchTerms = []; foreach ($searchArray as $value) { if (! empty($value)) { $cleanValue = preg_replace('/[^\w\s]/', ' ', (string) $value); $cleanValue = preg_replace('/\s+/', ' ', trim((string) $cleanValue)); if ($cleanValue !== '') { $searchTerms[] = $cleanValue; } } } return implode(' ', $searchTerms); } /** * @param array $criteria */ private function applyManticoreReleaseIndexFilters(Search $query, array $criteria): void { $releaseIds = $criteria['release_ids'] ?? null; if (is_array($releaseIds) && $releaseIds !== []) { $valid = array_values(array_filter( array_map(static fn ($id): int => (int) $id, $releaseIds), static fn (int $id): bool => $id > 0 )); if (count($valid) === 1) { $query->filter('id', '=', $valid[0]); } elseif (count($valid) > 1) { $query->filter('id', 'in', $valid); } } $categoryIds = $criteria['category_ids'] ?? null; if (is_array($categoryIds) && $categoryIds !== []) { $valid = array_values(array_filter($categoryIds, static fn ($id): bool => (int) $id > 0)); if (count($valid) === 1) { $query->filter('categories_id', '=', (int) $valid[0]); } elseif (count($valid) > 1) { $query->filter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $valid)); } } $excluded = $criteria['excluded_category_ids'] ?? []; if ($excluded !== []) { $query->notFilter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $excluded)); } $minSize = (int) ($criteria['min_size'] ?? 0); if ($minSize > 0) { $query->filter('size', 'gte', $minSize); } $maxSize = (int) ($criteria['max_size'] ?? 0); if ($maxSize > 0) { $query->filter('size', 'lte', $maxSize); } $maxAge = (int) ($criteria['max_age_days'] ?? 0); if ($maxAge > 0) { $cutoff = time() - ($maxAge * 86400); $query->filter('postdate_ts', 'gte', $cutoff); } $minDate = (int) ($criteria['min_date'] ?? 0); if ($minDate > 0) { $query->filter('postdate_ts', 'gte', $minDate); } $maxDate = (int) ($criteria['max_date'] ?? 0); if ($maxDate > 0) { $query->filter('postdate_ts', 'lte', $maxDate); } $gid = $criteria['groups_id'] ?? null; if ($gid !== null && (int) $gid > 0) { $query->filter('groups_id', '=', (int) $gid); } $allowRar = (bool) ($criteria['password_allow_rar'] ?? false); if ($allowRar) { $query->filter('passwordstatus', 'lte', 1); } else { $query->filter('passwordstatus', 'lte', 0); } if (array_key_exists('password_status_min', $criteria) && $criteria['password_status_min'] !== null) { $query->filter('passwordstatus', 'gte', (int) $criteria['password_status_min']); } } public function getSecondaryIndexName(SecondarySearchIndex $index): string { $configured = $this->config['indexes'][$index->value] ?? null; if (is_string($configured) && $configured !== '') { return $configured; } return match ($index) { SecondarySearchIndex::Music => 'music_rt', SecondarySearchIndex::Books => 'books_rt', SecondarySearchIndex::Games => 'games_rt', SecondarySearchIndex::Console => 'console_rt', SecondarySearchIndex::Steam => 'steam_rt', SecondarySearchIndex::Anime => 'anime_rt', }; } public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void { if ($id <= 0) { return; } try { $this->createIndexIfNotExists($this->getSecondaryIndexName($index)); $this->manticoreSearch->table($this->getSecondaryIndexName($index)) ->replaceDocument($document, $id); } catch (ResponseException $e) { Log::error('ManticoreSearch insertSecondary ResponseException: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertSecondary error: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } public function updateSecondary(SecondarySearchIndex $index, int $id): void { if ($id <= 0) { return; } try { match ($index) { SecondarySearchIndex::Music => $this->insertSecondary($index, $id, SecondaryIndexDocuments::music(MusicInfo::query()->findOrFail($id))), SecondarySearchIndex::Books => $this->insertSecondary($index, $id, SecondaryIndexDocuments::book(BookInfo::query()->findOrFail($id))), SecondarySearchIndex::Games => $this->insertSecondary($index, $id, SecondaryIndexDocuments::games(GamesInfo::query()->findOrFail($id))), SecondarySearchIndex::Console => $this->insertSecondary($index, $id, SecondaryIndexDocuments::console(ConsoleInfo::query()->findOrFail($id))), SecondarySearchIndex::Steam => $this->insertSecondary($index, $id, SecondaryIndexDocuments::steam(SteamApp::query()->findOrFail($id))), SecondarySearchIndex::Anime => null, }; } catch (\Throwable $e) { Log::warning('ManticoreSearch updateSecondary skipped: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } public function deleteSecondary(SecondarySearchIndex $index, int $id): void { if ($id <= 0) { return; } try { $this->manticoreSearch->table($this->getSecondaryIndexName($index)) ->deleteDocument($id); } catch (\Throwable $e) { Log::error('ManticoreSearch deleteSecondary error: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } /** * @param array> $documents * @return array */ public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array { if ($documents === []) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $rows = []; $table = $this->getSecondaryIndexName($index); foreach ($documents as $doc) { $docId = (int) ($doc['id'] ?? 0); if ($docId <= 0) { $errors++; continue; } unset($doc['id']); $rows[] = array_merge(['id' => $docId], $doc); } if ($rows === []) { return ['success' => 0, 'errors' => $errors]; } try { $this->createIndexIfNotExists($table); $this->manticoreSearch->table($table)->replaceDocuments($rows); $success = count($rows); } catch (\Throwable $e) { Log::error('ManticoreSearch bulkInsertSecondary error: '.$e->getMessage(), ['secondary' => $index->value]); $errors += count($rows); } return ['success' => $success, 'errors' => $errors]; } /** * @return array{id: list, data: list>} */ public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array { $query = trim($query); if ($query === '') { return ['id' => [], 'data' => []]; } $result = $this->searchIndexes( $this->getSecondaryIndexName($index), $query, $index->fulltextFields(), [], $this->normalizeSearchLimit($limit) ); return [ 'id' => array_map(static fn ($v): int => (int) $v, $result['id'] ?? []), 'data' => $result['data'] ?? [], ]; } /** * @return list */ public function searchAnimeTitle(string $query, int $limit = 100): array { $hits = $this->searchSecondary(SecondarySearchIndex::Anime, $query, $limit); $seen = []; foreach ($hits['data'] as $row) { $aid = (int) ($row['anidbid'] ?? 0); if ($aid > 0) { $seen[$aid] = true; } } return array_keys($seen); } }