Further improve returned search results

This commit is contained in:
DariusIII
2026-04-23 13:53:00 +02:00
parent 9190d69c0b
commit d8b19cb346
5 changed files with 235 additions and 21 deletions
@@ -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<string>
*/
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<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>>
*/
@@ -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 === []) {
+37
View File
@@ -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') {
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ElasticSearchQueryTest extends TestCase
{
#[Test]
public function it_uses_release_text_fields_constant_with_searchname_variants_only(): void
{
$driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php');
$this->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.'
);
}
}
+32
View File
@@ -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
{