From d8b19cb346d0cc1f668b795413d5cf4c3ff52e8d Mon Sep 17 00:00:00 2001 From: DariusIII Date: Thu, 23 Apr 2026 13:53:00 +0200 Subject: [PATCH] Further improve returned search results --- .../Search/Drivers/ElasticSearchDriver.php | 131 +++++++++++++++--- .../Search/Drivers/ManticoreSearchDriver.php | 4 +- tests/Feature/MovieSearchApiTest.php | 37 +++++ tests/Unit/ElasticSearchQueryTest.php | 52 +++++++ tests/Unit/ManticoreSearchQueryTest.php | 32 +++++ 5 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/ElasticSearchQueryTest.php diff --git a/app/Services/Search/Drivers/ElasticSearchDriver.php b/app/Services/Search/Drivers/ElasticSearchDriver.php index 5b2aa9dfe..24ffdfd23 100644 --- a/app/Services/Search/Drivers/ElasticSearchDriver.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -51,6 +51,13 @@ class ElasticSearchDriver implements SearchDriverInterface private const AUTOCOMPLETE_MIN_LENGTH = 2; + /** + * Keep release text searches scoped to release title variants only. + * + * @var list + */ + private const RELEASE_TEXT_FIELDS = ['searchname^3', 'plainsearchname^2']; + private static ?Client $client = null; private static ?bool $availabilityCache = null; @@ -844,7 +851,7 @@ class ElasticSearchDriver implements SearchDriverInterface $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, - fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'], // @phpstan-ignore argument.type + fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit ); @@ -891,7 +898,7 @@ class ElasticSearchDriver implements SearchDriverInterface $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, - fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'], // @phpstan-ignore argument.type + fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit ); @@ -938,7 +945,7 @@ class ElasticSearchDriver implements SearchDriverInterface $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, - fields: ['searchname^2', 'plainsearchname^1.5'], // @phpstan-ignore argument.type + fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit, options: [ 'boost' => 1.2, @@ -2792,8 +2799,9 @@ class ElasticSearchDriver implements SearchDriverInterface [ 'multi_match' => [ 'query' => $searchTerm, - 'fields' => ['searchname^3', 'name^2', 'filename'], - 'type' => 'best_fields', + 'fields' => self::RELEASE_TEXT_FIELDS, + 'type' => 'cross_fields', + 'operator' => 'and', 'fuzziness' => 'AUTO', ], ], @@ -2862,24 +2870,46 @@ class ElasticSearchDriver implements SearchDriverInterface $must = []; if ($hasText) { - $text = is_string($phrases) - ? $phrases - : implode(' ', array_values(array_filter( + 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 ))); - if ($text === '') { - return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; + $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]; } - $multi = [ - 'query' => $text, - 'fields' => ['searchname^3', 'name^2', 'filename', 'plainsearchname'], - 'type' => 'best_fields', - ]; - if ($useFuzzy && $this->isFuzzyEnabled()) { - $multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; - } - $must[] = ['multi_match' => $multi]; } if ($must !== [] && $filter !== []) { @@ -2946,6 +2976,69 @@ class ElasticSearchDriver implements SearchDriverInterface return $run(false); } + /** + * @param array $phrases + * @return list> + */ + 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 $phrases + */ + private function isAssociativeStringArray(array $phrases): bool + { + return count(array_filter(array_keys($phrases), 'is_string')) > 0; + } + /** * @return list> */ diff --git a/app/Services/Search/Drivers/ManticoreSearchDriver.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php index 2ec7f6fb4..7f1ef5076 100644 --- a/app/Services/Search/Drivers/ManticoreSearchDriver.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -2349,7 +2349,7 @@ class ManticoreSearchDriver implements SearchDriverInterface return $this->searchReleasesByCategory($categoryIds, $limit); } - $searchExpr = '@@relaxed @searchname '.$preparedSearch; + $searchExpr = '@@relaxed '.self::scopePreparedQueryToField($preparedSearch, '@searchname'); $query = (new Search($this->manticoreSearch)) ->setTable($this->getReleasesIndex()) @@ -2428,7 +2428,7 @@ class ManticoreSearchDriver implements SearchDriverInterface } $prepared = self::prepareUserSearchQuery((string) $value); if ($prepared !== '') { - $terms[] = '@@relaxed @'.$key.' '.$prepared; + $terms[] = '@@relaxed '.self::scopePreparedQueryToField($prepared, '@'.$key); } } if ($terms === []) { diff --git a/tests/Feature/MovieSearchApiTest.php b/tests/Feature/MovieSearchApiTest.php index dbda9fcc1..ebe043ea8 100644 --- a/tests/Feature/MovieSearchApiTest.php +++ b/tests/Feature/MovieSearchApiTest.php @@ -135,6 +135,43 @@ final class MovieSearchApiTest extends TestCase $this->assertNotContains('Titanic.The.Digital.Resurrection.2025.720p.WEBRip-LAMA', $results->pluck('searchname')->all()); } + #[Test] + public function api_search_uses_search_releases_filtered_with_search_phrase(): void + { + $mock = Mockery::mock(SearchService::class, [$this->app]); + $mock->shouldReceive('isAvailable')->andReturn(true); + $mock->shouldReceive('searchReleasesFiltered') + ->once() + ->withArgs(function (array $criteria, int $limit, int $offset): bool { + return ($criteria['phrases'] ?? null) === 'Resurrection 2025' + && ($criteria['try_fuzzy'] ?? null) === true + && $limit === 100 + && $offset === 0; + }) + ->andReturn([ + 'ids' => [], + 'total' => 0, + 'fuzzy' => false, + ]); + + $this->app->instance(SearchService::class, $mock); + + $service = new ReleaseSearchService; + $results = $service->apiSearch( + 'Resurrection 2025', + -1, + 0, + 100, + -1, + [], + [2030], + 0, + 'posted_desc' + ); + + $this->assertCount(0, $results); + } + private function registerSqliteConcatIfNeeded(): void { if (DB::getDriverName() !== 'sqlite') { diff --git a/tests/Unit/ElasticSearchQueryTest.php b/tests/Unit/ElasticSearchQueryTest.php new file mode 100644 index 000000000..a65df4b77 --- /dev/null +++ b/tests/Unit/ElasticSearchQueryTest.php @@ -0,0 +1,52 @@ +assertIsString($driverSource); + $this->assertStringContainsString( + "private const RELEASE_TEXT_FIELDS = ['searchname^3', 'plainsearchname^2'];", + $driverSource + ); + } + + #[Test] + public function search_releases_filtered_uses_cross_fields_and_operator_and_for_release_text_queries(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString("'fields' => self::RELEASE_TEXT_FIELDS", $driverSource); + $this->assertStringContainsString("'type' => 'cross_fields'", $driverSource); + $this->assertStringContainsString("'operator' => 'and'", $driverSource); + $this->assertStringNotContainsString( + "'fields' => ['searchname^3', 'name^2', 'filename', 'plainsearchname']", + $driverSource + ); + $this->assertStringNotContainsString("'type' => 'best_fields'", $driverSource); + } + + #[Test] + public function releases_index_search_entrypoints_use_release_text_fields_constant(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertGreaterThanOrEqual( + 3, + substr_count($driverSource, 'fields: self::RELEASE_TEXT_FIELDS'), + 'Expected indexSearch/indexSearchApi/indexSearchTMA to use RELEASE_TEXT_FIELDS.' + ); + } +} diff --git a/tests/Unit/ManticoreSearchQueryTest.php b/tests/Unit/ManticoreSearchQueryTest.php index ba52f3dbc..be2c813a9 100644 --- a/tests/Unit/ManticoreSearchQueryTest.php +++ b/tests/Unit/ManticoreSearchQueryTest.php @@ -237,6 +237,38 @@ class ManticoreSearchQueryTest extends TestCase $this->assertStringContainsString("\$query->sort('id', \$order);", $driverSource); } + #[Test] + public function search_releases_filtered_scopes_field_queries_with_grouping_parentheses(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ManticoreSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString( + "\$terms[] = '@@relaxed '.self::scopePreparedQueryToField(\$prepared, '@'.\$key);", + $driverSource + ); + $this->assertStringNotContainsString( + "\$terms[] = '@@relaxed @'.\$key.' '.\$prepared;", + $driverSource + ); + } + + #[Test] + public function search_releases_with_category_filter_scopes_searchname_with_grouping_parentheses(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ManticoreSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString( + "\$searchExpr = '@@relaxed '.self::scopePreparedQueryToField(\$preparedSearch, '@searchname');", + $driverSource + ); + $this->assertStringNotContainsString( + "\$searchExpr = '@@relaxed @searchname '.\$preparedSearch;", + $driverSource + ); + } + #[Test] public function search_releases_filtered_uses_stable_id_tiebreak_sort_for_elasticsearch(): void {