mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
3364 lines
110 KiB
PHP
3364 lines
110 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Search\Drivers;
|
|
|
|
use App\Enums\SecondarySearchIndex;
|
|
use App\Models\BookInfo;
|
|
use App\Models\ConsoleInfo;
|
|
use App\Models\GamesInfo;
|
|
use App\Models\MovieInfo;
|
|
use App\Models\MusicInfo;
|
|
use App\Models\Release;
|
|
use App\Models\SteamApp;
|
|
use App\Models\Video;
|
|
use App\Services\Search\Contracts\SearchDriverInterface;
|
|
use App\Services\Search\DTO\ReleaseSearchQuery;
|
|
use App\Services\Search\DTO\SearchPage;
|
|
use App\Services\Search\Support\ElasticsearchClientFactory;
|
|
use App\Services\Search\Support\ElasticsearchResponseHelper;
|
|
use App\Support\SecondaryIndexDocuments;
|
|
use Elastic\Elasticsearch\Client;
|
|
use Elastic\Elasticsearch\Exception\ElasticsearchException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Elasticsearch driver for full-text search functionality.
|
|
*
|
|
* Provides search functionality for the releases and predb indices with
|
|
* caching, connection pooling, and automatic reconnection.
|
|
*/
|
|
class ElasticSearchDriver implements SearchDriverInterface
|
|
{
|
|
private const CACHE_TTL_MINUTES = 5;
|
|
|
|
private const SCROLL_TIMEOUT = '30s';
|
|
|
|
private const MAX_RESULTS = 10000;
|
|
|
|
private const AVAILABILITY_CHECK_CACHE_TTL = 30; // seconds
|
|
|
|
private const DEFAULT_TIMEOUT = 10;
|
|
|
|
private const DEFAULT_CONNECT_TIMEOUT = 5;
|
|
|
|
private const AUTOCOMPLETE_CACHE_MINUTES = 10;
|
|
|
|
private const AUTOCOMPLETE_MAX_RESULTS = 10;
|
|
|
|
private const AUTOCOMPLETE_MIN_LENGTH = 2;
|
|
|
|
/**
|
|
* Keep release text searches scoped to release title variants only.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const RELEASE_TEXT_FIELDS = ['searchname^3', 'plainsearchname^2'];
|
|
|
|
private static ?Client $client = null;
|
|
|
|
private static ?bool $availabilityCache = null;
|
|
|
|
private static ?int $availabilityCacheTime = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
protected array $config;
|
|
|
|
/**
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public function __construct(array $config = [])
|
|
{
|
|
$this->config = ! empty($config) ? $config : config('search.drivers.elasticsearch');
|
|
}
|
|
|
|
/**
|
|
* Get the driver name.
|
|
*/
|
|
public function getDriverName(): string
|
|
{
|
|
return 'elasticsearch';
|
|
}
|
|
|
|
/**
|
|
* Check if Elasticsearch is available.
|
|
*/
|
|
public function isAvailable(): bool
|
|
{
|
|
return $this->isElasticsearchAvailable();
|
|
}
|
|
|
|
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'])),
|
|
documents: $result['documents'] ?? [],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get or create an Elasticsearch client with proper cURL configuration.
|
|
*
|
|
* Uses a singleton pattern to reuse the client connection across requests.
|
|
*
|
|
* @throws RuntimeException When client initialization fails
|
|
*/
|
|
private function getClient(): Client
|
|
{
|
|
if (self::$client === null) {
|
|
try {
|
|
if (empty($this->config)) {
|
|
throw new RuntimeException('Elasticsearch configuration not found');
|
|
}
|
|
|
|
if (config('app.debug')) {
|
|
Log::debug('Elasticsearch client initializing', [
|
|
'hosts' => ElasticsearchClientFactory::buildHosts($this->config['hosts'] ?? []),
|
|
]);
|
|
}
|
|
|
|
self::$client = ElasticsearchClientFactory::make($this->config);
|
|
|
|
if (config('app.debug')) {
|
|
Log::debug('Elasticsearch client initialized successfully');
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('Failed to initialize Elasticsearch client: '.$e->getMessage(), [
|
|
'exception_class' => get_class($e),
|
|
]);
|
|
throw new RuntimeException('Elasticsearch client initialization failed: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
return self::$client;
|
|
}
|
|
|
|
/**
|
|
* Check if Elasticsearch is available.
|
|
*
|
|
* Caches the availability status to avoid frequent ping requests.
|
|
*/
|
|
private function isElasticsearchAvailable(): bool
|
|
{
|
|
$now = time();
|
|
|
|
// Return cached result if still valid
|
|
if (self::$availabilityCache !== null
|
|
&& self::$availabilityCacheTime !== null
|
|
&& ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) {
|
|
return self::$availabilityCache;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$result = ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->ping());
|
|
|
|
if (config('app.debug')) {
|
|
Log::debug('Elasticsearch ping result', ['available' => $result]);
|
|
}
|
|
|
|
self::$availabilityCache = $result;
|
|
self::$availabilityCacheTime = $now;
|
|
|
|
return $result;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Elasticsearch is not available: '.$e->getMessage(), [
|
|
'exception_class' => get_class($e),
|
|
]);
|
|
|
|
self::$availabilityCache = false;
|
|
self::$availabilityCacheTime = $now;
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the client connection (useful for testing or reconnection).
|
|
*/
|
|
public function resetConnection(): void
|
|
{
|
|
self::$client = null;
|
|
self::$availabilityCache = null;
|
|
self::$availabilityCacheTime = null;
|
|
}
|
|
|
|
/**
|
|
* Check if autocomplete is enabled.
|
|
*/
|
|
public function isAutocompleteEnabled(): bool
|
|
{
|
|
return ($this->config['autocomplete']['enabled'] ?? true) && $this->isElasticsearchAvailable();
|
|
}
|
|
|
|
/**
|
|
* Check if suggest is enabled.
|
|
*/
|
|
public function isSuggestEnabled(): bool
|
|
{
|
|
return ($this->config['suggest']['enabled'] ?? true) && $this->isElasticsearchAvailable();
|
|
}
|
|
|
|
/**
|
|
* Check if fuzzy search is enabled.
|
|
*/
|
|
public function isFuzzyEnabled(): bool
|
|
{
|
|
return ($this->config['fuzzy']['enabled'] ?? true) && $this->isElasticsearchAvailable();
|
|
}
|
|
|
|
/**
|
|
* Get fuzzy search configuration.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getFuzzyConfig(): array
|
|
{
|
|
return $this->config['fuzzy'] ?? [
|
|
'enabled' => true,
|
|
'fuzziness' => 'AUTO',
|
|
'prefix_length' => 2,
|
|
'max_expansions' => 50,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the releases index name.
|
|
*/
|
|
public function getReleasesIndex(): string
|
|
{
|
|
return $this->config['indexes']['releases'] ?? 'releases';
|
|
}
|
|
|
|
/**
|
|
* Get the predb index name.
|
|
*/
|
|
public function getPredbIndex(): string
|
|
{
|
|
return $this->config['indexes']['predb'] ?? 'predb';
|
|
}
|
|
|
|
/**
|
|
* Get the movies index name.
|
|
*/
|
|
public function getMoviesIndex(): string
|
|
{
|
|
return $this->config['indexes']['movies'] ?? 'movies';
|
|
}
|
|
|
|
/**
|
|
* Get the TV shows index name.
|
|
*/
|
|
public function getTvShowsIndex(): string
|
|
{
|
|
return $this->config['indexes']['tvshows'] ?? 'tvshows';
|
|
}
|
|
|
|
/**
|
|
* Escape special characters for Elasticsearch.
|
|
*/
|
|
public static function escapeString(string $string): string
|
|
{
|
|
if (empty($string) || $string === '*') {
|
|
return '';
|
|
}
|
|
|
|
// Replace dots with spaces for release name searches
|
|
$string = str_replace('.', ' ', $string);
|
|
|
|
// Remove multiple consecutive spaces
|
|
$string = preg_replace('/\s+/', ' ', trim($string));
|
|
|
|
return $string;
|
|
}
|
|
|
|
/**
|
|
* 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<array{suggest: string, distance: int, docs: int}>
|
|
*/
|
|
public function autocomplete(string $query, ?string $index = null): array
|
|
{
|
|
if (! $this->isAutocompleteEnabled()) {
|
|
return [];
|
|
}
|
|
|
|
$query = trim($query);
|
|
$minLength = (int) ($this->config['autocomplete']['min_length'] ?? self::AUTOCOMPLETE_MIN_LENGTH);
|
|
if (strlen($query) < $minLength) {
|
|
return [];
|
|
}
|
|
|
|
$index = $index ?? $this->getReleasesIndex();
|
|
$cacheKey = 'es:autocomplete:'.md5($index.$query);
|
|
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
$suggestions = [];
|
|
$maxResults = (int) ($this->config['autocomplete']['max_results'] ?? self::AUTOCOMPLETE_MAX_RESULTS);
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Use a prefix/match query on searchname field
|
|
$searchParams = [
|
|
'index' => $index,
|
|
'body' => [
|
|
'query' => [
|
|
'bool' => [
|
|
'should' => [
|
|
// Prefix match for autocomplete-like behavior
|
|
[
|
|
'match_phrase_prefix' => [
|
|
'searchname' => [
|
|
'query' => $query,
|
|
'max_expansions' => 50,
|
|
],
|
|
],
|
|
],
|
|
// Also include regular match for better results
|
|
[
|
|
'match' => [
|
|
'searchname' => [
|
|
'query' => $query,
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'minimum_should_match' => 1,
|
|
],
|
|
],
|
|
'size' => $maxResults * 3,
|
|
'_source' => ['searchname'],
|
|
'sort' => [
|
|
// Sort by date first to get latest results, then by score
|
|
['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']],
|
|
['_score' => ['order' => 'desc']],
|
|
],
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$seen = [];
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$searchname = $hit['_source']['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('ElasticSearch autocomplete error: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
if (! empty($suggestions)) {
|
|
$cacheMinutes = (int) ($this->config['autocomplete']['cache_minutes'] ?? self::AUTOCOMPLETE_CACHE_MINUTES);
|
|
Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
|
|
}
|
|
|
|
return $suggestions;
|
|
}
|
|
|
|
/**
|
|
* 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<array{suggest: string, distance: int, docs: int}>
|
|
*/
|
|
public function suggest(string $query, ?string $index = null): array
|
|
{
|
|
if (! $this->isSuggestEnabled()) {
|
|
return [];
|
|
}
|
|
|
|
$query = trim($query);
|
|
if (empty($query)) {
|
|
return [];
|
|
}
|
|
|
|
$index = $index ?? $this->getReleasesIndex();
|
|
$cacheKey = 'es:suggest:'.md5($index.$query);
|
|
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
$suggestions = [];
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Use Elasticsearch suggest API with phrase suggester
|
|
$searchParams = [
|
|
'index' => $index,
|
|
'body' => [
|
|
'suggest' => [
|
|
'text' => $query,
|
|
'searchname_suggest' => [
|
|
'phrase' => [
|
|
'field' => 'searchname',
|
|
'size' => 5,
|
|
'gram_size' => 3,
|
|
'direct_generator' => [
|
|
[
|
|
'field' => 'searchname',
|
|
'suggest_mode' => 'popular',
|
|
],
|
|
],
|
|
'highlight' => [
|
|
'pre_tag' => '',
|
|
'post_tag' => '',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
if (isset($response['suggest']['searchname_suggest'][0]['options'])) {
|
|
foreach ($response['suggest']['searchname_suggest'][0]['options'] as $option) {
|
|
$suggestedText = $option['text'] ?? '';
|
|
if (! empty($suggestedText) && strtolower($suggestedText) !== strtolower($query)) {
|
|
$suggestions[] = [
|
|
'suggest' => $suggestedText,
|
|
'distance' => 1,
|
|
'docs' => (int) ($option['freq'] ?? 1),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch native suggest failed: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Fallback: if native suggest didn't work, use fuzzy search
|
|
if (empty($suggestions)) {
|
|
$suggestions = $this->suggestFallback($query, $index);
|
|
}
|
|
|
|
if (! empty($suggestions)) {
|
|
Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
}
|
|
|
|
return $suggestions;
|
|
}
|
|
|
|
/**
|
|
* Fallback suggest using fuzzy search on searchnames.
|
|
*
|
|
* @param string $query The search query
|
|
* @param string $index Index to search
|
|
* @return array<array{suggest: string, distance: int, docs: int}>
|
|
*/
|
|
private function suggestFallback(string $query, string $index): array
|
|
{
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Use fuzzy match to find similar terms
|
|
$searchParams = [
|
|
'index' => $index,
|
|
'body' => [
|
|
'query' => [
|
|
'match' => [
|
|
'searchname' => [
|
|
'query' => $query,
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
],
|
|
],
|
|
'size' => 20,
|
|
'_source' => ['searchname'],
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
// Extract common terms from results that differ from the query
|
|
$termCounts = [];
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$searchname = $hit['_source']['searchname'] ?? '';
|
|
$words = preg_split('/[\s.\-_]+/', strtolower($searchname));
|
|
|
|
foreach ($words as $word) {
|
|
if (strlen($word) >= 3 && $word !== strtolower($query)) {
|
|
$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
|
|
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('ElasticSearch suggest fallback error: '.$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
$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
|
|
$pos = stripos($clean, $query);
|
|
if ($pos !== false) {
|
|
$start = max(0, $pos - 10);
|
|
$extracted = substr($clean, $start, 80);
|
|
|
|
if ($start > 0) {
|
|
$extracted = preg_replace('/^\S*\s/', '', $extracted);
|
|
}
|
|
$extracted = preg_replace('/\s\S*$/', '', $extracted);
|
|
|
|
return trim($extracted);
|
|
}
|
|
|
|
return substr($clean, 0, 80);
|
|
}
|
|
|
|
/**
|
|
* Search releases index.
|
|
*
|
|
* @param array<string, mixed>|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<string, mixed> Array of release IDs
|
|
*/
|
|
public function searchReleases(array|string $phrases, int $limit = 1000): array
|
|
{
|
|
// Normalize the input to a search string
|
|
if (is_string($phrases)) {
|
|
$searchString = $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) {
|
|
// Extract values from associative array
|
|
$searchString = implode(' ', array_values($phrases));
|
|
} else {
|
|
// Indexed array - combine values
|
|
$searchString = implode(' ', $phrases);
|
|
}
|
|
}
|
|
|
|
$result = $this->indexSearch($searchString, $limit);
|
|
|
|
return is_array($result) ? $result : $result->toArray();
|
|
}
|
|
|
|
/**
|
|
* 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, mixed>|string $phrases Search phrases
|
|
* @param int $limit Maximum number of results
|
|
* @param bool $forceFuzzy Force fuzzy search regardless of exact results
|
|
* @return array<string, mixed> 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,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 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 Elasticsearch's fuzzy matching to find results with typo tolerance.
|
|
*
|
|
* @param array<string, mixed>|string $phrases Search phrases
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed> Array of release IDs
|
|
*/
|
|
public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array
|
|
{
|
|
if (! $this->isFuzzyEnabled() || ! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
// Normalize the input to a search string
|
|
if (is_string($phrases)) {
|
|
$searchString = $phrases;
|
|
} else {
|
|
$isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
|
|
if ($isAssociative) {
|
|
$searchString = implode(' ', array_values($phrases));
|
|
} else {
|
|
$searchString = implode(' ', $phrases);
|
|
}
|
|
}
|
|
|
|
$keywords = $this->sanitizeSearchTerms($searchString);
|
|
if (empty($keywords)) {
|
|
return [];
|
|
}
|
|
|
|
$fuzzyConfig = $this->getFuzzyConfig();
|
|
$cacheKey = $this->buildCacheKey('fuzzy_search', [$keywords, $limit, $fuzzyConfig]); // @phpstan-ignore argument.type
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Build fuzzy query
|
|
$searchParams = [
|
|
'index' => $this->getReleasesIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'bool' => [
|
|
'should' => [
|
|
// Fuzzy match on searchname
|
|
[
|
|
'match' => [
|
|
'searchname' => [
|
|
'query' => $keywords,
|
|
'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
|
|
'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
|
|
'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
|
|
'boost' => 2,
|
|
],
|
|
],
|
|
],
|
|
// Fuzzy match on plainsearchname
|
|
[
|
|
'match' => [
|
|
'plainsearchname' => [
|
|
'query' => $keywords,
|
|
'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
|
|
'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
|
|
'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
|
|
'boost' => 1.5,
|
|
],
|
|
],
|
|
],
|
|
// Fuzzy match on name
|
|
[
|
|
'match' => [
|
|
'name' => [
|
|
'query' => $keywords,
|
|
'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
|
|
'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
|
|
'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
|
|
'boost' => 1.2,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'minimum_should_match' => 1,
|
|
],
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
'_source' => false,
|
|
'sort' => [
|
|
['_score' => ['order' => 'desc']],
|
|
],
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$ids = [];
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$ids[] = (int) $hit['_id'];
|
|
}
|
|
}
|
|
|
|
Cache::put($cacheKey, $ids, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
|
|
return $ids; // @phpstan-ignore return.type
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch fuzzySearchReleases error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
return [];
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch fuzzySearchReleases unexpected error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search the predb index.
|
|
*
|
|
* @param array<string, mixed>|string $searchTerm Search term(s)
|
|
* @return array<string, mixed> Array of predb records
|
|
*/
|
|
public function searchPredb(array|string $searchTerm): array
|
|
{
|
|
$result = $this->predbIndexSearch($searchTerm);
|
|
|
|
return is_array($result) ? $result : $result->toArray();
|
|
}
|
|
|
|
/**
|
|
* Search releases index.
|
|
*
|
|
* @param array<string, mixed>|string $phrases Search phrases
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed>|Collection<int, mixed> Array of release IDs
|
|
*/
|
|
public function indexSearch(array|string $phrases, int $limit): array|Collection
|
|
{
|
|
if (empty($phrases) || ! $this->isElasticsearchAvailable()) {
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch indexSearch: empty phrases or ES not available', [
|
|
'phrases_empty' => empty($phrases),
|
|
'es_available' => $this->isElasticsearchAvailable(),
|
|
]);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
$keywords = $this->sanitizeSearchTerms($phrases);
|
|
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch indexSearch: sanitized keywords', [
|
|
'original' => is_array($phrases) ? implode(' ', $phrases) : $phrases,
|
|
'sanitized' => $keywords,
|
|
]);
|
|
}
|
|
|
|
if (empty($keywords)) {
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch indexSearch: keywords empty after sanitization');
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
$cacheKey = $this->buildCacheKey('index_search', [$keywords, $limit]); // @phpstan-ignore argument.type
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$search = $this->buildSearchQuery(
|
|
index: $this->getReleasesIndex(),
|
|
keywords: $keywords,
|
|
fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type
|
|
limit: $limit
|
|
);
|
|
|
|
$result = $this->executeSearch($search);
|
|
Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch indexSearch error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search releases for API requests.
|
|
*
|
|
* @param array<string, mixed>|string $searchName Search name(s)
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed>|Collection<int, mixed> Array of release IDs
|
|
*/
|
|
public function indexSearchApi(array|string $searchName, int $limit): array|Collection
|
|
{
|
|
if (empty($searchName) || ! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
$keywords = $this->sanitizeSearchTerms($searchName);
|
|
if (empty($keywords)) {
|
|
return [];
|
|
}
|
|
|
|
$cacheKey = $this->buildCacheKey('api_search', [$keywords, $limit]); // @phpstan-ignore argument.type
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$search = $this->buildSearchQuery(
|
|
index: $this->getReleasesIndex(),
|
|
keywords: $keywords,
|
|
fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type
|
|
limit: $limit
|
|
);
|
|
|
|
$result = $this->executeSearch($search);
|
|
Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch indexSearchApi error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search releases for TV/Movie/Audio (TMA) matching.
|
|
*
|
|
* @param array<string, mixed>|string $name Name(s) to search
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed>|Collection<int, mixed> Array of release IDs
|
|
*/
|
|
public function indexSearchTMA(array|string $name, int $limit): array|Collection
|
|
{
|
|
if (empty($name) || ! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
$keywords = $this->sanitizeSearchTerms($name);
|
|
if (empty($keywords)) {
|
|
return [];
|
|
}
|
|
|
|
$cacheKey = $this->buildCacheKey('tma_search', [$keywords, $limit]); // @phpstan-ignore argument.type
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$search = $this->buildSearchQuery(
|
|
index: $this->getReleasesIndex(),
|
|
keywords: $keywords,
|
|
fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type
|
|
limit: $limit,
|
|
options: [
|
|
'boost' => 1.2,
|
|
]
|
|
);
|
|
|
|
$result = $this->executeSearch($search);
|
|
Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch indexSearchTMA error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search predb index.
|
|
*
|
|
* @param array<string, mixed>|string $searchTerm Search term(s)
|
|
* @return array<string, mixed>|Collection<int, mixed> Array of predb records
|
|
*/
|
|
public function predbIndexSearch(array|string $searchTerm): array|Collection
|
|
{
|
|
if (empty($searchTerm) || ! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
$keywords = $this->sanitizeSearchTerms($searchTerm);
|
|
if (empty($keywords)) {
|
|
return [];
|
|
}
|
|
|
|
$cacheKey = $this->buildCacheKey('predb_search', [$keywords]); // @phpstan-ignore argument.type
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$search = $this->buildSearchQuery(
|
|
index: $this->getPredbIndex(),
|
|
keywords: $keywords,
|
|
fields: ['title^2', 'filename'], // @phpstan-ignore argument.type
|
|
limit: 1000,
|
|
options: [
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
includeDateSort: false
|
|
);
|
|
|
|
$result = $this->executeSearch($search, fullResults: true);
|
|
Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch predbIndexSearch error: '.$e->getMessage(), [
|
|
'keywords' => $keywords,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a release into the index.
|
|
*
|
|
* @param array<string, mixed> $parameters Release data with 'id', 'name', 'searchname', etc.
|
|
*/
|
|
public function insertRelease(array $parameters): void
|
|
{
|
|
if (empty($parameters['id']) || ! $this->isElasticsearchAvailable()) {
|
|
if (empty($parameters['id'])) {
|
|
Log::warning('ElasticSearch: Cannot insert release without ID');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$client->index($this->buildReleaseDocument($parameters));
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch insertRelease error: '.$e->getMessage(), [
|
|
'release_id' => $parameters['id'],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch insertRelease unexpected error: '.$e->getMessage(), [
|
|
'release_id' => $parameters['id'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a release in the index.
|
|
*
|
|
* @param int|string $releaseID Release ID
|
|
*/
|
|
public function updateRelease(int|string $releaseID): void
|
|
{
|
|
if (empty($releaseID)) {
|
|
Log::warning('ElasticSearch: Cannot update release without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
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) {
|
|
Log::warning('ElasticSearch: Release not found for update, removing from index', ['id' => $releaseID]);
|
|
$this->deleteRelease((int) $releaseID);
|
|
|
|
return;
|
|
}
|
|
|
|
$client = $this->getClient();
|
|
$client->index($this->buildReleaseDocument($release->toArray()));
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [
|
|
'release_id' => $releaseID,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch updateRelease unexpected error: '.$e->getMessage(), [
|
|
'release_id' => $releaseID,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a predb record into the index.
|
|
*
|
|
* @param array<string, mixed> $parameters Predb data with 'id', 'title', 'source', 'filename'
|
|
*/
|
|
public function insertPredb(array $parameters): void
|
|
{
|
|
if (empty($parameters['id'])) {
|
|
Log::warning('ElasticSearch: Cannot insert predb without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$data = [
|
|
'body' => [
|
|
'id' => $parameters['id'],
|
|
'title' => $parameters['title'] ?? '',
|
|
'source' => $parameters['source'] ?? '',
|
|
'filename' => $parameters['filename'] ?? '',
|
|
],
|
|
'index' => $this->getPredbIndex(),
|
|
'id' => $parameters['id'],
|
|
];
|
|
|
|
$client = $this->getClient();
|
|
$client->index($data);
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch insertPreDb error: '.$e->getMessage(), [
|
|
'predb_id' => $parameters['id'],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch insertPreDb unexpected error: '.$e->getMessage(), [
|
|
'predb_id' => $parameters['id'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a predb record in the index.
|
|
*
|
|
* @param array<string, mixed> $parameters Predb data with 'id', 'title', 'source', 'filename'
|
|
*/
|
|
public function updatePreDb(array $parameters): void
|
|
{
|
|
if (empty($parameters['id'])) {
|
|
Log::warning('ElasticSearch: Cannot update predb without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$data = [
|
|
'body' => [
|
|
'doc' => [
|
|
'id' => $parameters['id'],
|
|
'title' => $parameters['title'] ?? '',
|
|
'filename' => $parameters['filename'] ?? '',
|
|
'source' => $parameters['source'] ?? '',
|
|
],
|
|
'doc_as_upsert' => true,
|
|
],
|
|
'index' => $this->getPredbIndex(),
|
|
'id' => $parameters['id'],
|
|
];
|
|
|
|
$client = $this->getClient();
|
|
$client->update($data);
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch updatePreDb error: '.$e->getMessage(), [
|
|
'predb_id' => $parameters['id'],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch updatePreDb unexpected error: '.$e->getMessage(), [
|
|
'predb_id' => $parameters['id'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a release from the index.
|
|
*
|
|
* @param int $id Release ID
|
|
*/
|
|
public function deleteRelease(int $id): void
|
|
{
|
|
$this->deleteReleases([$id]);
|
|
}
|
|
|
|
public function deleteReleases(iterable $ids): void
|
|
{
|
|
$ids = array_values(array_unique(array_filter(
|
|
array_map('intval', is_array($ids) ? $ids : iterator_to_array($ids)),
|
|
static fn (int $id): bool => $id > 0
|
|
)));
|
|
|
|
if ($ids === [] || ! $this->isElasticsearchAvailable()) {
|
|
if ($ids === []) {
|
|
Log::warning('ElasticSearch: Cannot delete release without ID');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$body = [];
|
|
foreach ($ids as $id) {
|
|
$body[] = ['delete' => ['_index' => $this->getReleasesIndex(), '_id' => $id]];
|
|
}
|
|
$client->bulk(['body' => $body]);
|
|
|
|
} catch (\Throwable $e) {
|
|
if (ElasticsearchResponseHelper::isNotFound($e)) {
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch deleteReleases: document not found', ['release_ids' => $ids]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($e instanceof ElasticsearchException) {
|
|
Log::error('ElasticSearch deleteReleases error: '.$e->getMessage(), [
|
|
'release_ids' => $ids,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
Log::error('ElasticSearch deleteReleases unexpected error: '.$e->getMessage(), [
|
|
'release_ids' => $ids,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a predb record from the index.
|
|
*
|
|
* @param int $id Predb ID
|
|
*/
|
|
public function deletePreDb(int $id): void
|
|
{
|
|
if (empty($id) || ! $this->isElasticsearchAvailable()) {
|
|
if (empty($id)) {
|
|
Log::warning('ElasticSearch: Cannot delete predb without ID');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$client->delete([
|
|
'index' => $this->getPredbIndex(),
|
|
'id' => $id,
|
|
]);
|
|
|
|
} catch (\Throwable $e) {
|
|
if (ElasticsearchResponseHelper::isNotFound($e)) {
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch deletePreDb: document not found', ['predb_id' => $id]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($e instanceof ElasticsearchException) {
|
|
Log::error('ElasticSearch deletePreDb error: '.$e->getMessage(), [
|
|
'predb_id' => $id,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
Log::error('ElasticSearch deletePreDb unexpected error: '.$e->getMessage(), [
|
|
'predb_id' => $id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk insert multiple releases into the index.
|
|
*
|
|
* @param array<string, mixed> $releases Array of release data arrays
|
|
* @return array<string, mixed> Results with 'success' and 'errors' counts
|
|
*/
|
|
public function bulkInsertReleases(array $releases): array
|
|
{
|
|
if (empty($releases) || ! $this->isElasticsearchAvailable()) {
|
|
return ['success' => 0, 'errors' => 0];
|
|
}
|
|
|
|
$success = 0;
|
|
$errors = 0;
|
|
|
|
$params = ['body' => []];
|
|
|
|
foreach ($releases as $release) {
|
|
if (empty($release['id'])) {
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$searchNameDotless = $this->createPlainSearchName($release['searchname'] ?? '');
|
|
|
|
$params['body'][] = [
|
|
'index' => [
|
|
'_index' => $this->getReleasesIndex(),
|
|
'_id' => $release['id'],
|
|
],
|
|
];
|
|
|
|
$params['body'][] = [
|
|
'id' => $release['id'],
|
|
'name' => (string) ($release['name'] ?? ''),
|
|
'searchname' => (string) ($release['searchname'] ?? ''),
|
|
'plainsearchname' => $searchNameDotless,
|
|
'fromname' => (string) ($release['fromname'] ?? ''),
|
|
'categories_id' => (int) ($release['categories_id'] ?? 0),
|
|
'filename' => (string) ($release['filename'] ?? ''),
|
|
];
|
|
|
|
$success++;
|
|
}
|
|
|
|
if (! empty($params['body'])) {
|
|
try {
|
|
$client = $this->getClient();
|
|
$response = $client->bulk($params);
|
|
|
|
if (isset($response['errors']) && $response['errors']) {
|
|
foreach ($response['items'] as $item) {
|
|
if (isset($item['index']['error'])) {
|
|
$errors++;
|
|
$success--;
|
|
if (config('app.debug')) {
|
|
Log::error('ElasticSearch bulkInsertReleases error: '.json_encode($item['index']['error']));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulkInsertReleases error: '.$e->getMessage());
|
|
$errors += $success;
|
|
$success = 0;
|
|
}
|
|
}
|
|
|
|
return ['success' => $success, 'errors' => $errors];
|
|
}
|
|
|
|
/**
|
|
* Bulk insert multiple predb records into the index.
|
|
*
|
|
* @param array<string, mixed> $predbRecords Array of predb data arrays
|
|
* @return array<string, mixed> Results with 'success' and 'errors' counts
|
|
*/
|
|
public function bulkInsertPredb(array $predbRecords): array
|
|
{
|
|
if (empty($predbRecords) || ! $this->isElasticsearchAvailable()) {
|
|
return ['success' => 0, 'errors' => 0];
|
|
}
|
|
|
|
$success = 0;
|
|
$errors = 0;
|
|
|
|
$params = ['body' => []];
|
|
|
|
foreach ($predbRecords as $predb) {
|
|
if (empty($predb['id'])) {
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$params['body'][] = [
|
|
'index' => [
|
|
'_index' => $this->getPredbIndex(),
|
|
'_id' => $predb['id'],
|
|
],
|
|
];
|
|
|
|
$params['body'][] = [
|
|
'id' => $predb['id'],
|
|
'title' => (string) ($predb['title'] ?? ''),
|
|
'filename' => (string) ($predb['filename'] ?? ''),
|
|
'source' => (string) ($predb['source'] ?? ''),
|
|
];
|
|
|
|
$success++;
|
|
}
|
|
|
|
if (! empty($params['body'])) {
|
|
try {
|
|
$client = $this->getClient();
|
|
$response = $client->bulk($params);
|
|
|
|
if (isset($response['errors']) && $response['errors']) {
|
|
foreach ($response['items'] as $item) {
|
|
if (isset($item['index']['error'])) {
|
|
$errors++;
|
|
$success--;
|
|
if (config('app.debug')) {
|
|
Log::error('ElasticSearch bulkInsertPredb error: '.json_encode($item['index']['error']));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulkInsertPredb error: '.$e->getMessage());
|
|
$errors += $success;
|
|
$success = 0;
|
|
}
|
|
}
|
|
|
|
return ['success' => $success, 'errors' => $errors];
|
|
}
|
|
|
|
/**
|
|
* Check if an index exists.
|
|
*
|
|
* @param string $index Index name
|
|
*/
|
|
public function indexExists(string $index): bool
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
return ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => $index]));
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch indexExists error: '.$e->getMessage(), ['index' => $index]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncate/clear an index (remove all documents).
|
|
* Implements SearchServiceInterface::truncateIndex
|
|
*
|
|
* @param array<string, mixed>|string $indexes Index name(s) to truncate
|
|
*/
|
|
public function truncateIndex(array|string $indexes): void
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return;
|
|
}
|
|
|
|
$indexArray = is_array($indexes) ? $indexes : [$indexes];
|
|
|
|
foreach ($indexArray as $index) {
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Check if index exists
|
|
if (! ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => $index]))) {
|
|
Log::info("ElasticSearch truncateIndex: index {$index} does not exist, skipping");
|
|
|
|
continue;
|
|
}
|
|
|
|
// Delete all documents from the index
|
|
$client->deleteByQuery([
|
|
'index' => $index,
|
|
'body' => [
|
|
'query' => ['match_all' => (object) []],
|
|
],
|
|
'conflicts' => 'proceed',
|
|
]);
|
|
|
|
// Force refresh to ensure deletions are visible
|
|
$client->indices()->refresh(['index' => $index]);
|
|
|
|
Log::info("ElasticSearch: Truncated index {$index}");
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error("ElasticSearch truncateIndex error for {$index}: ".$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimize index for better search performance.
|
|
* Implements SearchServiceInterface::optimizeIndex
|
|
*/
|
|
public function optimizeIndex(): void
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Force merge the releases index
|
|
$client->indices()->forcemerge([
|
|
'index' => $this->getReleasesIndex(),
|
|
'max_num_segments' => 1,
|
|
]);
|
|
|
|
// Force merge the predb index
|
|
$client->indices()->forcemerge([
|
|
'index' => $this->getPredbIndex(),
|
|
'max_num_segments' => 1,
|
|
]);
|
|
|
|
// Refresh both indexes
|
|
$client->indices()->refresh(['index' => $this->getReleasesIndex()]);
|
|
$client->indices()->refresh(['index' => $this->getPredbIndex()]);
|
|
|
|
Log::info('ElasticSearch: Optimized indexes');
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch optimizeIndex error: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cluster health information.
|
|
*
|
|
* @return array<string, mixed> Health information or empty array on failure
|
|
*/
|
|
public function getClusterHealth(): array
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
return ElasticsearchResponseHelper::asArray($client->cluster()->health());
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch getClusterHealth error: '.$e->getMessage());
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get index statistics.
|
|
*
|
|
* @param string $index Index name
|
|
* @return array<string, mixed> Statistics or empty array on failure
|
|
*/
|
|
public function getIndexStats(string $index): array
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
return ElasticsearchResponseHelper::asArray($client->indices()->stats(['index' => $index]));
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch getIndexStats error: '.$e->getMessage(), ['index' => $index]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize search terms for Elasticsearch.
|
|
*
|
|
* Uses the global sanitize() helper function which properly escapes
|
|
* Elasticsearch query string special characters.
|
|
*
|
|
* @param array<string, mixed>|string $terms Search terms
|
|
* @return string Sanitized search string
|
|
*/
|
|
private function sanitizeSearchTerms(array|string $terms): string
|
|
{
|
|
if (is_array($terms)) {
|
|
$terms = implode(' ', array_filter($terms));
|
|
}
|
|
|
|
$terms = trim($terms);
|
|
if (empty($terms)) {
|
|
return '';
|
|
}
|
|
|
|
// Use the original sanitize() helper function that properly handles
|
|
// Elasticsearch query string escaping
|
|
if (function_exists('sanitize')) {
|
|
return sanitize($terms);
|
|
}
|
|
|
|
// Fallback if sanitize function doesn't exist
|
|
// Replace dots with spaces for release name searches
|
|
$terms = str_replace('.', ' ', $terms);
|
|
|
|
// Remove multiple consecutive spaces
|
|
$terms = preg_replace('/\s+/', ' ', trim($terms));
|
|
|
|
return $terms;
|
|
}
|
|
|
|
/**
|
|
* Build a cache key for search results.
|
|
*
|
|
* @param string $prefix Cache key prefix
|
|
* @param array<string, mixed> $params Parameters to include in key
|
|
*/
|
|
private function buildCacheKey(string $prefix, array $params): string
|
|
{
|
|
return 'es_'.$prefix.'_'.md5(serialize($params));
|
|
}
|
|
|
|
/**
|
|
* Build a search query array.
|
|
*
|
|
* @param string $index Index name
|
|
* @param string $keywords Sanitized keywords
|
|
* @param array<string, mixed> $fields Fields to search with boosts
|
|
* @param int $limit Maximum results
|
|
* @param array<string, mixed> $options Additional query_string options
|
|
* @param bool $includeDateSort Include date sorting
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildSearchQuery(
|
|
string $index,
|
|
string $keywords,
|
|
array $fields,
|
|
int $limit,
|
|
array $options = [],
|
|
bool $includeDateSort = true,
|
|
bool $useScroll = false
|
|
): array {
|
|
$queryString = array_merge([
|
|
'query' => $keywords,
|
|
'fields' => $fields,
|
|
'analyze_wildcard' => true,
|
|
'default_operator' => 'and',
|
|
], $options);
|
|
|
|
$sort = [['_score' => ['order' => 'desc']]];
|
|
|
|
if ($includeDateSort) {
|
|
$sort[] = ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
|
|
$sort[] = ['post_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
|
|
}
|
|
|
|
$search = [
|
|
'index' => $index,
|
|
'body' => [
|
|
'query' => [
|
|
'query_string' => $queryString,
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
'sort' => $sort,
|
|
'_source' => ['id'],
|
|
'track_total_hits' => true,
|
|
],
|
|
];
|
|
|
|
if ($useScroll) {
|
|
$search['scroll'] = self::SCROLL_TIMEOUT;
|
|
}
|
|
|
|
return $search;
|
|
}
|
|
|
|
/**
|
|
* Build a release document for indexing.
|
|
*
|
|
* @param array<string, mixed> $parameters Release parameters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildReleaseDocument(array $parameters): array
|
|
{
|
|
$searchNameDotless = $this->createPlainSearchName($parameters['searchname'] ?? '');
|
|
|
|
return [
|
|
'body' => [
|
|
'id' => $parameters['id'],
|
|
'name' => $parameters['name'] ?? '',
|
|
'searchname' => $parameters['searchname'] ?? '',
|
|
'plainsearchname' => $searchNameDotless,
|
|
'fromname' => $parameters['fromname'] ?? '',
|
|
'categories_id' => $parameters['categories_id'] ?? 0,
|
|
'imdbid' => (string) ($parameters['imdbid'] ?? ''),
|
|
'filename' => $parameters['filename'] ?? '',
|
|
'add_date' => now()->format('Y-m-d H:i:s'),
|
|
'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'),
|
|
],
|
|
'index' => $this->getReleasesIndex(),
|
|
'id' => $parameters['id'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Create a plain search name by removing dots and dashes.
|
|
*
|
|
* @param string $searchName Original search name
|
|
*/
|
|
private function createPlainSearchName(string $searchName): string
|
|
{
|
|
return str_replace(['.', '-'], ' ', $searchName);
|
|
}
|
|
|
|
/**
|
|
* Execute a search with scroll support.
|
|
*
|
|
* @param array<string, mixed> $search Search query
|
|
* @param bool $fullResults Return full source documents instead of just IDs
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function executeSearch(array $search, bool $fullResults = false): array
|
|
{
|
|
if (empty($search) || ! $this->isElasticsearchAvailable()) {
|
|
return [];
|
|
}
|
|
|
|
$scrollId = null;
|
|
$useScroll = isset($search['scroll']) && is_string($search['scroll']) && $search['scroll'] !== '';
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
// Log the search query for debugging
|
|
if (config('app.debug')) {
|
|
Log::debug('ElasticSearch executing search', [
|
|
'index' => $search['index'] ?? 'unknown',
|
|
'query' => $search['body']['query'] ?? [],
|
|
]);
|
|
}
|
|
|
|
$results = $client->search($search);
|
|
|
|
// Log the number of hits
|
|
if (config('app.debug')) {
|
|
$totalHits = $results['hits']['total']['value'] ?? $results['hits']['total'] ?? 0;
|
|
Log::debug('ElasticSearch search results', [
|
|
'total_hits' => $totalHits,
|
|
'returned_hits' => count($results['hits']['hits'] ?? []),
|
|
]);
|
|
}
|
|
|
|
$searchResult = [];
|
|
|
|
foreach ($results['hits']['hits'] ?? [] as $result) {
|
|
if ($fullResults) {
|
|
$searchResult[] = $result['_source'];
|
|
} else {
|
|
$searchResult[] = $result['_source']['id'] ?? $result['_id'];
|
|
}
|
|
}
|
|
|
|
if (! $useScroll) {
|
|
return $searchResult;
|
|
}
|
|
|
|
while (isset($results['hits']['hits']) && count($results['hits']['hits']) > 0) {
|
|
|
|
// Handle scrolling for large result sets
|
|
if (! isset($results['_scroll_id'])) {
|
|
break;
|
|
}
|
|
|
|
$scrollId = $results['_scroll_id'];
|
|
$results = $client->scroll([
|
|
'scroll_id' => $scrollId,
|
|
'scroll' => self::SCROLL_TIMEOUT,
|
|
]);
|
|
|
|
foreach ($results['hits']['hits'] ?? [] as $result) {
|
|
if ($fullResults) {
|
|
$searchResult[] = $result['_source'];
|
|
} else {
|
|
$searchResult[] = $result['_source']['id'] ?? $result['_id'];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $searchResult; // @phpstan-ignore return.type
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch search error: '.$e->getMessage());
|
|
|
|
return [];
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch search unexpected error: '.$e->getMessage());
|
|
|
|
return [];
|
|
} finally {
|
|
// Clean up scroll context
|
|
$this->clearScrollContext($scrollId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear a scroll context to free server resources.
|
|
*
|
|
* @param string|null $scrollId Scroll ID to clear
|
|
*/
|
|
private function clearScrollContext(?string $scrollId): void
|
|
{
|
|
if ($scrollId === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$client->clearScroll(['scroll_id' => $scrollId]);
|
|
} catch (\Throwable $e) {
|
|
// Ignore errors when clearing scroll - it's just cleanup
|
|
if (config('app.debug')) {
|
|
Log::debug('Failed to clear scroll context: '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a bulk operation.
|
|
*
|
|
* @param array<string, mixed> $params Bulk operation parameters
|
|
*/
|
|
private function executeBulk(array $params): void
|
|
{
|
|
if (empty($params['body'])) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$response = $client->bulk($params);
|
|
|
|
if (! empty($response['errors'])) {
|
|
foreach ($response['items'] as $item) {
|
|
$operation = $item['index'] ?? $item['update'] ?? $item['delete'] ?? [];
|
|
if (isset($operation['error'])) {
|
|
Log::error('ElasticSearch bulk operation error', [
|
|
'id' => $operation['_id'] ?? 'unknown',
|
|
'error' => $operation['error'],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch bulk error: '.$e->getMessage());
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulk unexpected error: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a movie into the movies search index.
|
|
*
|
|
* @param array<string, mixed> $parameters Movie data
|
|
*/
|
|
public function insertMovie(array $parameters): void
|
|
{
|
|
if (empty($parameters['id'])) {
|
|
Log::warning('ElasticSearch: Cannot insert movie without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$document = [
|
|
'id' => $parameters['id'],
|
|
'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'] ?? ''),
|
|
];
|
|
|
|
$client->index([
|
|
'index' => $this->getMoviesIndex(),
|
|
'id' => $parameters['id'],
|
|
'body' => $document,
|
|
]);
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch insertMovie error: '.$e->getMessage(), [
|
|
'movie_id' => $parameters['id'],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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('ElasticSearch: Cannot update movie without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$movie = MovieInfo::find($movieId);
|
|
|
|
if ($movie !== null) {
|
|
$this->insertMovie($movie->toArray());
|
|
} else {
|
|
Log::warning('ElasticSearch: Movie not found for update', ['id' => $movieId]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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('ElasticSearch: Cannot delete movie without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$client->delete([
|
|
'index' => $this->getMoviesIndex(),
|
|
'id' => $id,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
if (ElasticsearchResponseHelper::isNotFound($e)) {
|
|
return;
|
|
}
|
|
|
|
if ($e instanceof ElasticsearchException) {
|
|
Log::error('ElasticSearch deleteMovie error: '.$e->getMessage(), [
|
|
'id' => $id,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk insert multiple movies into the index.
|
|
*
|
|
* @param array<string, mixed> $movies Array of movie data arrays
|
|
* @return array<string, mixed> Results with 'success' and 'errors' counts
|
|
*/
|
|
public function bulkInsertMovies(array $movies): array
|
|
{
|
|
if (empty($movies)) {
|
|
return ['success' => 0, 'errors' => 0];
|
|
}
|
|
|
|
$success = 0;
|
|
$errors = 0;
|
|
|
|
$params = ['body' => []];
|
|
|
|
foreach ($movies as $movie) {
|
|
if (empty($movie['id'])) {
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$params['body'][] = [
|
|
'index' => [
|
|
'_index' => $this->getMoviesIndex(),
|
|
'_id' => $movie['id'],
|
|
],
|
|
];
|
|
|
|
$params['body'][] = [
|
|
'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'] ?? ''),
|
|
];
|
|
|
|
$success++;
|
|
}
|
|
|
|
if (! empty($params['body'])) {
|
|
try {
|
|
$this->executeBulk($params);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulkInsertMovies error: '.$e->getMessage());
|
|
$errors += $success;
|
|
$success = 0;
|
|
}
|
|
}
|
|
|
|
return ['success' => $success, 'errors' => $errors];
|
|
}
|
|
|
|
/**
|
|
* Search the movies index.
|
|
*
|
|
* @param array<string, mixed>|string $searchTerm Search term(s)
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed> 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;
|
|
$escapedSearch = self::escapeString($searchString);
|
|
|
|
if (empty($escapedSearch)) {
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
|
|
$cacheKey = 'es:movies:'.md5($escapedSearch.$limit);
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getMoviesIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'multi_match' => [
|
|
'query' => $escapedSearch,
|
|
'fields' => ['title^3', 'actors', 'director', 'genre'],
|
|
'type' => 'best_fields',
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$resultIds = [];
|
|
$resultData = [];
|
|
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$resultIds[] = $hit['_id'];
|
|
$resultData[] = $hit['_source'];
|
|
}
|
|
}
|
|
|
|
$result = ['id' => $resultIds, 'data' => $resultData];
|
|
|
|
Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchMovies error: '.$e->getMessage());
|
|
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@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->isElasticsearchAvailable()) {
|
|
return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []];
|
|
}
|
|
|
|
$must = [];
|
|
foreach ($filtered as $field => $value) {
|
|
$must[] = [
|
|
'match' => [
|
|
$field => [
|
|
'query' => $value,
|
|
'operator' => 'and',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$response = $client->search([
|
|
'index' => $this->getMoviesIndex(),
|
|
'body' => [
|
|
'query' => ['bool' => ['must' => $must]],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
],
|
|
]);
|
|
|
|
$imdbids = [];
|
|
$movieinfoIds = [];
|
|
$data = [];
|
|
|
|
foreach ($response['hits']['hits'] ?? [] as $hit) {
|
|
$movieinfoIds[] = (int) ($hit['_id'] ?? 0);
|
|
$src = $hit['_source'] ?? [];
|
|
$data[] = $src;
|
|
$imdb = (string) ($src['imdbid'] ?? '');
|
|
if ($imdb !== '') {
|
|
$imdbids[] = $imdb;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'imdbids' => array_values(array_unique($imdbids)),
|
|
'movieinfo_ids' => $movieinfoIds,
|
|
'data' => $data,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchMoviesByFields error: '.$e->getMessage());
|
|
|
|
return ['imdbids' => [], 'movieinfo_ids' => [], '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<string, mixed>|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<string, mixed> $externalIds
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function searchMovieByExternalIds(array $externalIds): ?array
|
|
{
|
|
$allowedFields = ['imdbid', 'tmdbid', 'traktid'];
|
|
$shouldClauses = [];
|
|
$cacheableValues = [];
|
|
|
|
foreach ($allowedFields as $field) {
|
|
$value = $externalIds[$field] ?? null;
|
|
if (empty($value)) {
|
|
continue;
|
|
}
|
|
|
|
$typedValue = $field === 'imdbid' ? (string) $value : (int) $value;
|
|
if ($typedValue === '' || $typedValue === 0) {
|
|
continue;
|
|
}
|
|
|
|
$shouldClauses[] = ['term' => [$field => $typedValue]];
|
|
$cacheableValues[$field] = $typedValue;
|
|
}
|
|
|
|
if ($shouldClauses === []) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = 'es:movie:any:'.md5(serialize($cacheableValues));
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getMoviesIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'bool' => [
|
|
'should' => $shouldClauses,
|
|
'minimum_should_match' => 1,
|
|
],
|
|
],
|
|
'size' => 1,
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
if (isset($response['hits']['hits'][0])) {
|
|
$data = $response['hits']['hits'][0]['_source'];
|
|
$data['id'] = $response['hits']['hits'][0]['_id'];
|
|
Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
|
|
return $data;
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchMovieByExternalIds error: '.$e->getMessage(), [
|
|
'externalIds' => $externalIds,
|
|
]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Insert a TV show into the tvshows search index.
|
|
*
|
|
* @param array<string, mixed> $parameters TV show data
|
|
*/
|
|
public function insertTvShow(array $parameters): void
|
|
{
|
|
if (empty($parameters['id'])) {
|
|
Log::warning('ElasticSearch: Cannot insert TV show without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$document = [
|
|
'id' => $parameters['id'],
|
|
'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),
|
|
];
|
|
|
|
$client->index([
|
|
'index' => $this->getTvShowsIndex(),
|
|
'id' => $parameters['id'],
|
|
'body' => $document,
|
|
]);
|
|
|
|
} catch (ElasticsearchException $e) {
|
|
Log::error('ElasticSearch insertTvShow error: '.$e->getMessage(), [
|
|
'tvshow_id' => $parameters['id'],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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('ElasticSearch: Cannot update TV show without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$video = Video::find($videoId);
|
|
|
|
if ($video !== null) {
|
|
$this->insertTvShow($video->toArray());
|
|
} else {
|
|
Log::warning('ElasticSearch: TV show not found for update', ['id' => $videoId]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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('ElasticSearch: Cannot delete TV show without ID');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$client->delete([
|
|
'index' => $this->getTvShowsIndex(),
|
|
'id' => $id,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
if (ElasticsearchResponseHelper::isNotFound($e)) {
|
|
return;
|
|
}
|
|
|
|
if ($e instanceof ElasticsearchException) {
|
|
Log::error('ElasticSearch deleteTvShow error: '.$e->getMessage(), [
|
|
'id' => $id,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk insert multiple TV shows into the index.
|
|
*
|
|
* @param array<string, mixed> $tvShows Array of TV show data arrays
|
|
* @return array<string, mixed> Results with 'success' and 'errors' counts
|
|
*/
|
|
public function bulkInsertTvShows(array $tvShows): array
|
|
{
|
|
if (empty($tvShows)) {
|
|
return ['success' => 0, 'errors' => 0];
|
|
}
|
|
|
|
$success = 0;
|
|
$errors = 0;
|
|
|
|
$params = ['body' => []];
|
|
|
|
foreach ($tvShows as $tvShow) {
|
|
if (empty($tvShow['id'])) {
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$params['body'][] = [
|
|
'index' => [
|
|
'_index' => $this->getTvShowsIndex(),
|
|
'_id' => $tvShow['id'],
|
|
],
|
|
];
|
|
|
|
$params['body'][] = [
|
|
'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),
|
|
];
|
|
|
|
$success++;
|
|
}
|
|
|
|
if (! empty($params['body'])) {
|
|
try {
|
|
$this->executeBulk($params);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulkInsertTvShows error: '.$e->getMessage());
|
|
$errors += $success;
|
|
$success = 0;
|
|
}
|
|
}
|
|
|
|
return ['success' => $success, 'errors' => $errors];
|
|
}
|
|
|
|
/**
|
|
* Search the TV shows index.
|
|
*
|
|
* @param array<string, mixed>|string $searchTerm Search term(s)
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed> 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;
|
|
$escapedSearch = self::escapeString($searchString);
|
|
|
|
if (empty($escapedSearch)) {
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
|
|
$cacheKey = 'es:tvshows:'.md5($escapedSearch.$limit);
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getTvShowsIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'match' => [
|
|
'title' => [
|
|
'query' => $escapedSearch,
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
],
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$resultIds = [];
|
|
$resultData = [];
|
|
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$resultIds[] = $hit['_id'];
|
|
$resultData[] = $hit['_source'];
|
|
}
|
|
}
|
|
|
|
$result = ['id' => $resultIds, 'data' => $resultData];
|
|
|
|
Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
|
|
return $result;
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchTvShows error: '.$e->getMessage());
|
|
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, mixed>|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<string, mixed> $externalIds
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function searchTvShowByExternalIds(array $externalIds): ?array
|
|
{
|
|
$allowedFields = ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'];
|
|
$shouldClauses = [];
|
|
$cacheableValues = [];
|
|
|
|
foreach ($allowedFields as $field) {
|
|
$value = $externalIds[$field] ?? null;
|
|
if (empty($value)) {
|
|
continue;
|
|
}
|
|
|
|
$typedValue = $field === 'imdb' ? (string) $value : (int) $value;
|
|
if ($typedValue === '' || $typedValue === 0) {
|
|
continue;
|
|
}
|
|
|
|
$shouldClauses[] = ['term' => [$field => $typedValue]];
|
|
$cacheableValues[$field] = $typedValue;
|
|
}
|
|
|
|
if ($shouldClauses === []) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = 'es:tvshow:any:'.md5(serialize($cacheableValues));
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getTvShowsIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'bool' => [
|
|
'should' => $shouldClauses,
|
|
'minimum_should_match' => 1,
|
|
],
|
|
],
|
|
'size' => 1,
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
if (isset($response['hits']['hits'][0])) {
|
|
$data = $response['hits']['hits'][0]['_source'];
|
|
$data['id'] = $response['hits']['hits'][0]['_id'];
|
|
Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
|
|
return $data;
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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<string, mixed> $externalIds Associative array of external IDs
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed> Array of release IDs
|
|
*/
|
|
public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array
|
|
{
|
|
return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $externalIdSets
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array
|
|
{
|
|
if ($externalIdSets === []) {
|
|
return [];
|
|
}
|
|
|
|
$shouldClauses = [];
|
|
$cachePayload = [];
|
|
|
|
foreach ($externalIdSets as $externalIds) {
|
|
$setClauses = $this->buildReleaseExternalIdShouldClauses($externalIds);
|
|
if ($setClauses === []) {
|
|
continue;
|
|
}
|
|
|
|
$shouldClauses[] = [
|
|
'bool' => [
|
|
'should' => $setClauses,
|
|
'minimum_should_match' => 1,
|
|
],
|
|
];
|
|
$cachePayload[] = $externalIds;
|
|
}
|
|
|
|
if ($shouldClauses === []) {
|
|
return [];
|
|
}
|
|
|
|
$cacheKey = 'es:releases:extid_sets:'.md5(serialize([$cachePayload, $limit]));
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getReleasesIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'bool' => [
|
|
'should' => $shouldClauses,
|
|
'minimum_should_match' => 1,
|
|
],
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
'_source' => false,
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$resultIds = [];
|
|
foreach ($response['hits']['hits'] ?? [] as $hit) {
|
|
$resultIds[] = $hit['_id'];
|
|
}
|
|
|
|
if ($resultIds !== []) {
|
|
Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
}
|
|
|
|
return $resultIds; // @phpstan-ignore return.type
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchReleasesByMultipleExternalIds error: '.$e->getMessage(), [
|
|
'externalIdSets' => $externalIdSets,
|
|
]);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $externalIds
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildReleaseExternalIdShouldClauses(array $externalIds): array
|
|
{
|
|
$clauses = [];
|
|
foreach ($externalIds as $field => $value) {
|
|
if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$clauses[] = ['term' => [$field => $field === 'imdbid' ? (string) $value : (int) $value]];
|
|
}
|
|
|
|
return $clauses;
|
|
}
|
|
|
|
/**
|
|
* 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<string, mixed> $categoryIds Array of category IDs to filter by
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed>
|
|
*/
|
|
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 = 'es:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit);
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$searchParams = [
|
|
'index' => $this->getReleasesIndex(),
|
|
'body' => [
|
|
'query' => [
|
|
'terms' => [
|
|
'categories_id' => array_map('intval', $validCategoryIds),
|
|
],
|
|
],
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
'_source' => false,
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$resultIds = [];
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$resultIds[] = $hit['_id'];
|
|
}
|
|
}
|
|
|
|
if (! empty($resultIds)) {
|
|
Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
}
|
|
|
|
return $resultIds; // @phpstan-ignore return.type
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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<string, mixed> $categoryIds Array of category IDs to filter by (empty for all categories)
|
|
* @param int $limit Maximum number of results
|
|
* @return array<string, mixed>
|
|
*/
|
|
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 = 'es:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit);
|
|
$cached = Cache::get($cacheKey);
|
|
if ($cached !== null) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
|
|
$query = [
|
|
'bool' => [
|
|
'must' => [
|
|
[
|
|
'multi_match' => [
|
|
'query' => $searchTerm,
|
|
'fields' => self::RELEASE_TEXT_FIELDS,
|
|
'type' => 'cross_fields',
|
|
'operator' => 'and',
|
|
'fuzziness' => 'AUTO',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
// Add category filter if provided
|
|
if (! empty($validCategoryIds)) {
|
|
$query['bool']['filter'] = [
|
|
'terms' => [
|
|
'categories_id' => array_map('intval', $validCategoryIds),
|
|
],
|
|
];
|
|
}
|
|
|
|
$searchParams = [
|
|
'index' => $this->getReleasesIndex(),
|
|
'body' => [
|
|
'query' => $query,
|
|
'size' => min($limit, self::MAX_RESULTS),
|
|
'_source' => false,
|
|
],
|
|
];
|
|
|
|
$response = $client->search($searchParams);
|
|
|
|
$resultIds = [];
|
|
if (isset($response['hits']['hits'])) {
|
|
foreach ($response['hits']['hits'] as $hit) {
|
|
$resultIds[] = $hit['_id'];
|
|
}
|
|
}
|
|
|
|
if (! empty($resultIds)) {
|
|
Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
|
|
}
|
|
|
|
return $resultIds;
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [
|
|
'searchTerm' => $searchTerm,
|
|
'categoryIds' => $categoryIds,
|
|
]);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array
|
|
{
|
|
if (! $this->isElasticsearchAvailable()) {
|
|
return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false];
|
|
}
|
|
|
|
$phrases = $criteria['phrases'] ?? null;
|
|
$hasText = $phrases !== null && $phrases !== '' && $phrases !== -1;
|
|
$tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true);
|
|
|
|
$run = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array {
|
|
$filter = $this->buildElasticsearchReleaseFilters($criteria);
|
|
$must = [];
|
|
|
|
if ($hasText) {
|
|
if (is_string($phrases)) {
|
|
$text = trim($phrases);
|
|
if ($text === '') {
|
|
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy];
|
|
}
|
|
$multi = [
|
|
'query' => $text,
|
|
'fields' => self::RELEASE_TEXT_FIELDS,
|
|
'type' => 'cross_fields',
|
|
'operator' => 'and',
|
|
];
|
|
if ($useFuzzy && $this->isFuzzyEnabled()) {
|
|
$multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO';
|
|
}
|
|
$must[] = ['multi_match' => $multi];
|
|
} elseif (is_array($phrases) && $this->isAssociativeStringArray($phrases)) {
|
|
$must = $this->buildReleaseFieldSpecificMustClauses($phrases, $useFuzzy);
|
|
if ($must === []) {
|
|
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy];
|
|
}
|
|
} else {
|
|
$text = implode(' ', array_values(array_filter(
|
|
is_array($phrases) ? $phrases : [],
|
|
static fn ($v): bool => $v !== null && $v !== '' && $v !== -1
|
|
)));
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy];
|
|
}
|
|
$multi = [
|
|
'query' => $text,
|
|
'fields' => self::RELEASE_TEXT_FIELDS,
|
|
'type' => 'cross_fields',
|
|
'operator' => 'and',
|
|
];
|
|
if ($useFuzzy && $this->isFuzzyEnabled()) {
|
|
$multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO';
|
|
}
|
|
$must[] = ['multi_match' => $multi];
|
|
}
|
|
}
|
|
|
|
if ($must !== [] && $filter !== []) {
|
|
$query = ['bool' => ['must' => $must, 'filter' => $filter]];
|
|
} elseif ($must !== []) {
|
|
$query = ['bool' => ['must' => $must]];
|
|
} elseif ($filter !== []) {
|
|
$query = ['bool' => ['filter' => $filter]];
|
|
} else {
|
|
$query = ['match_all' => new \stdClass];
|
|
}
|
|
|
|
$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';
|
|
}
|
|
if ($sortField === 'id') {
|
|
$sortField = 'postdate_ts';
|
|
}
|
|
$order = $sortDir === 'asc' ? 'asc' : 'desc';
|
|
|
|
try {
|
|
$client = $this->getClient();
|
|
$body = [
|
|
'query' => $query,
|
|
'sort' => [
|
|
[$sortField => ['order' => $order]],
|
|
['id' => ['order' => $order]],
|
|
],
|
|
'size' => max(1, min($limit, self::MAX_RESULTS)),
|
|
'track_total_hits' => (bool) ($criteria['track_total'] ?? true),
|
|
'_source' => false,
|
|
];
|
|
$cursorSort = $criteria['cursor_sort'] ?? null;
|
|
if (is_array($cursorSort) && count($cursorSort) === 2) {
|
|
$body['search_after'] = array_values($cursorSort);
|
|
} else {
|
|
$body['from'] = max(0, $offset);
|
|
}
|
|
|
|
$response = $client->search([
|
|
'index' => $this->getReleasesIndex(),
|
|
'body' => $body,
|
|
]);
|
|
|
|
$ids = [];
|
|
$hits = $response['hits']['hits'] ?? [];
|
|
foreach ($hits as $hit) {
|
|
$ids[] = (int) ($hit['_id'] ?? 0);
|
|
}
|
|
$total = (int) ($response['hits']['total']['value'] ?? $response['hits']['total'] ?? 0);
|
|
$lastHit = $hits === [] ? null : $hits[array_key_last($hits)];
|
|
|
|
return [
|
|
'ids' => $ids,
|
|
'total' => $total,
|
|
'fuzzy' => $useFuzzy,
|
|
'available' => true,
|
|
'last_sort' => is_array($lastHit) && is_array($lastHit['sort'] ?? null) ? array_values($lastHit['sort']) : [],
|
|
'has_more' => count($ids) === max(1, min($limit, self::MAX_RESULTS)) && ($total === 0 || count($ids) + $offset < $total),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchReleasesFiltered error: '.$e->getMessage());
|
|
|
|
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false];
|
|
}
|
|
};
|
|
|
|
if ($hasText) {
|
|
$first = $run(false);
|
|
if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled()) {
|
|
return $first;
|
|
}
|
|
|
|
return $run(true);
|
|
}
|
|
|
|
return $run(false);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $phrases
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildReleaseFieldSpecificMustClauses(array $phrases, bool $useFuzzy): array
|
|
{
|
|
$fieldMap = [
|
|
'searchname' => self::RELEASE_TEXT_FIELDS,
|
|
'plainsearchname' => ['plainsearchname'],
|
|
'name' => ['name'],
|
|
'fromname' => ['fromname'],
|
|
'filename' => ['filename'],
|
|
];
|
|
|
|
$must = [];
|
|
foreach ($phrases as $field => $rawValue) {
|
|
if (! is_string($field) || ! isset($fieldMap[$field])) {
|
|
continue;
|
|
}
|
|
|
|
$value = is_scalar($rawValue) ? trim((string) $rawValue) : '';
|
|
if ($value === '' || $value === '-1') {
|
|
continue;
|
|
}
|
|
|
|
$fields = $fieldMap[$field];
|
|
if (count($fields) > 1) {
|
|
$multi = [
|
|
'query' => $value,
|
|
'fields' => $fields,
|
|
'type' => 'cross_fields',
|
|
'operator' => 'and',
|
|
];
|
|
if ($useFuzzy && $this->isFuzzyEnabled()) {
|
|
$multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO';
|
|
}
|
|
$must[] = ['multi_match' => $multi];
|
|
|
|
continue;
|
|
}
|
|
|
|
$fieldName = $fields[0];
|
|
$match = [
|
|
'query' => $value,
|
|
'operator' => 'and',
|
|
];
|
|
if ($useFuzzy && $this->isFuzzyEnabled()) {
|
|
$match['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO';
|
|
}
|
|
$must[] = ['match' => [$fieldName => $match]];
|
|
}
|
|
|
|
return $must;
|
|
}
|
|
|
|
/**
|
|
* @param array<string|int, mixed> $phrases
|
|
*/
|
|
private function isAssociativeStringArray(array $phrases): bool
|
|
{
|
|
return count(array_filter(array_keys($phrases), 'is_string')) > 0;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildElasticsearchReleaseFilters(array $criteria): array
|
|
{
|
|
$filter = [];
|
|
|
|
$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 ($valid !== []) {
|
|
$filter[] = ['ids' => ['values' => array_map(static fn (int $id): string => (string) $id, $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 ($valid !== []) {
|
|
$filter[] = ['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $valid)]];
|
|
}
|
|
}
|
|
|
|
$excluded = $criteria['excluded_category_ids'] ?? [];
|
|
if ($excluded !== []) {
|
|
$filter[] = ['bool' => ['must_not' => [
|
|
['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $excluded)]],
|
|
]]];
|
|
}
|
|
|
|
$minSize = (int) ($criteria['min_size'] ?? 0);
|
|
if ($minSize > 0) {
|
|
$filter[] = ['range' => ['size' => ['gte' => $minSize]]];
|
|
}
|
|
|
|
$maxSize = (int) ($criteria['max_size'] ?? 0);
|
|
if ($maxSize > 0) {
|
|
$filter[] = ['range' => ['size' => ['lte' => $maxSize]]];
|
|
}
|
|
|
|
$maxAge = (int) ($criteria['max_age_days'] ?? 0);
|
|
if ($maxAge > 0) {
|
|
$cutoff = time() - ($maxAge * 86400);
|
|
$filter[] = ['range' => ['postdate_ts' => ['gte' => $cutoff]]];
|
|
}
|
|
|
|
$minDate = (int) ($criteria['min_date'] ?? 0);
|
|
if ($minDate > 0) {
|
|
$filter[] = ['range' => ['postdate_ts' => ['gte' => $minDate]]];
|
|
}
|
|
|
|
$maxDate = (int) ($criteria['max_date'] ?? 0);
|
|
if ($maxDate > 0) {
|
|
$filter[] = ['range' => ['postdate_ts' => ['lte' => $maxDate]]];
|
|
}
|
|
|
|
$gid = $criteria['groups_id'] ?? null;
|
|
if ($gid !== null && (int) $gid > 0) {
|
|
$filter[] = ['term' => ['groups_id' => (int) $gid]];
|
|
}
|
|
|
|
$allowRar = (bool) ($criteria['password_allow_rar'] ?? false);
|
|
if ($allowRar) {
|
|
$filter[] = ['range' => ['passwordstatus' => ['lte' => 1]]];
|
|
} else {
|
|
$filter[] = ['range' => ['passwordstatus' => ['lte' => 0]]];
|
|
}
|
|
|
|
if (array_key_exists('password_status_min', $criteria) && $criteria['password_status_min'] !== null) {
|
|
$filter[] = ['range' => ['passwordstatus' => ['gte' => (int) $criteria['password_status_min']]]];
|
|
}
|
|
|
|
return $filter;
|
|
}
|
|
|
|
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',
|
|
SecondarySearchIndex::Books => 'books',
|
|
SecondarySearchIndex::Games => 'games',
|
|
SecondarySearchIndex::Console => 'console',
|
|
SecondarySearchIndex::Steam => 'steam',
|
|
SecondarySearchIndex::Anime => 'anime',
|
|
};
|
|
}
|
|
|
|
public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void
|
|
{
|
|
if ($id <= 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->getClient()->index([
|
|
'index' => $this->getSecondaryIndexName($index),
|
|
'id' => (string) $id,
|
|
'body' => $document,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch 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('ElasticSearch updateSecondary skipped: '.$e->getMessage(), [
|
|
'secondary' => $index->value,
|
|
'id' => $id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function deleteSecondary(SecondarySearchIndex $index, int $id): void
|
|
{
|
|
if ($id <= 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->getClient()->delete([
|
|
'index' => $this->getSecondaryIndexName($index),
|
|
'id' => (string) $id,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::debug('ElasticSearch deleteSecondary: '.$e->getMessage(), [
|
|
'secondary' => $index->value,
|
|
'id' => $id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $documents
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array
|
|
{
|
|
if ($documents === []) {
|
|
return ['success' => 0, 'errors' => 0];
|
|
}
|
|
|
|
$validDocs = [];
|
|
$errors = 0;
|
|
$indexName = $this->getSecondaryIndexName($index);
|
|
|
|
foreach ($documents as $doc) {
|
|
$docId = (int) ($doc['id'] ?? 0);
|
|
if ($docId <= 0) {
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
unset($doc['id']);
|
|
$validDocs[] = ['id' => $docId, 'body' => $doc];
|
|
}
|
|
|
|
if ($validDocs === []) {
|
|
return ['success' => 0, 'errors' => $errors];
|
|
}
|
|
|
|
$params = ['body' => []];
|
|
foreach ($validDocs as $doc) {
|
|
$params['body'][] = [
|
|
'index' => [
|
|
'_index' => $indexName,
|
|
'_id' => (string) $doc['id'],
|
|
],
|
|
];
|
|
$params['body'][] = $doc['body'];
|
|
}
|
|
|
|
$success = count($validDocs);
|
|
try {
|
|
$this->executeBulk($params);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch bulkInsertSecondary error: '.$e->getMessage(), ['secondary' => $index->value]);
|
|
$errors += $success;
|
|
$success = 0;
|
|
}
|
|
|
|
return ['success' => $success, 'errors' => $errors];
|
|
}
|
|
|
|
/**
|
|
* @return array{id: list<int>, data: list<array<string, mixed>>}
|
|
*/
|
|
public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array
|
|
{
|
|
$query = trim($query);
|
|
if ($query === '') {
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
|
|
$fields = $index->fulltextFields();
|
|
$escapedSearch = self::escapeString($query);
|
|
if ($escapedSearch === '') {
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
|
|
try {
|
|
$response = $this->getClient()->search([
|
|
'index' => $this->getSecondaryIndexName($index),
|
|
'body' => [
|
|
'query' => [
|
|
'multi_match' => [
|
|
'query' => $escapedSearch,
|
|
'fields' => $fields,
|
|
'operator' => 'and',
|
|
],
|
|
],
|
|
'size' => max(1, min($limit, self::MAX_RESULTS)),
|
|
'_source' => true,
|
|
],
|
|
]);
|
|
|
|
$ids = [];
|
|
$data = [];
|
|
foreach ($response['hits']['hits'] ?? [] as $hit) {
|
|
$ids[] = (int) ($hit['_id'] ?? 0);
|
|
$data[] = (array) ($hit['_source'] ?? []);
|
|
}
|
|
|
|
return ['id' => $ids, 'data' => $data];
|
|
} catch (\Throwable $e) {
|
|
Log::error('ElasticSearch searchSecondary error: '.$e->getMessage(), ['secondary' => $index->value]);
|
|
|
|
return ['id' => [], 'data' => []];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
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);
|
|
}
|
|
}
|