mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Improve search handling in manticore and elasticsearch
This commit is contained in:
@@ -10,6 +10,10 @@ DB_DATABASE=nntmux
|
||||
COMPOSER_AUTH='{"github-oauth": {"github.com": "YOUR GITHUB TOKEN"}}'
|
||||
|
||||
SEARCH_DRIVER=manticore
|
||||
# Increment after rebuilding search indexes to invalidate API v2 cursors.
|
||||
SEARCH_INDEX_GENERATION=1
|
||||
# Lifetime of signed API v2 search cursors.
|
||||
SEARCH_CURSOR_TTL_MINUTES=15
|
||||
|
||||
# ManticoreSearch Configuration
|
||||
MANTICORESEARCH_HOST=localhost
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Data\Api\ReleaseData;
|
||||
use App\Facades\Search;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Controllers\GetNzbController;
|
||||
use App\Models\Category;
|
||||
@@ -18,6 +19,8 @@ use App\Services\Api\ApiUserResolver;
|
||||
use App\Services\Api\V2\ApiV2Presenter;
|
||||
use App\Services\Releases\ReleaseBrowseService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
use App\Services\Search\SearchCursorCodec;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -47,6 +50,11 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
private ApiCapabilitiesService $capabilitiesService;
|
||||
|
||||
private SearchCursorCodec $cursorCodec;
|
||||
|
||||
/** @var array{enabled: bool, offset: int, limit: int, query_hash: string, cursor: SearchCursor|null} */
|
||||
private array $paginationContext = ['enabled' => false, 'offset' => 0, 'limit' => 0, 'query_hash' => '', 'cursor' => null];
|
||||
|
||||
/**
|
||||
* @var array<int, object>
|
||||
*/
|
||||
@@ -61,6 +69,7 @@ class ApiV2Controller extends BasePageController
|
||||
?ApiUserResolver $userResolver = null,
|
||||
?ApiV2Presenter $presenter = null,
|
||||
?ApiCapabilitiesService $capabilitiesService = null,
|
||||
?SearchCursorCodec $cursorCodec = null,
|
||||
) {
|
||||
$this->releaseSearchService = $releaseSearchService;
|
||||
$this->releaseBrowseService = $releaseBrowseService;
|
||||
@@ -70,6 +79,7 @@ class ApiV2Controller extends BasePageController
|
||||
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
|
||||
$this->presenter = $presenter ?? app(ApiV2Presenter::class);
|
||||
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
||||
$this->cursorCodec = $cursorCodec ?? app(SearchCursorCodec::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +167,63 @@ class ApiV2Controller extends BasePageController
|
||||
*/
|
||||
private function buildSearchResponse(iterable $rows, User $user): JsonResponse
|
||||
{
|
||||
return $this->presenter->search($rows, $user, $this->buildUserStatsResponse($user));
|
||||
$rows = is_array($rows) ? $rows : iterator_to_array($rows, false);
|
||||
$pagination = null;
|
||||
if ($this->paginationContext['enabled']) {
|
||||
$total = (int) ($rows[0]->_totalrows ?? 0);
|
||||
$nextOffset = $this->paginationContext['offset'] + count($rows);
|
||||
$hasMore = (bool) ($rows[0]->_search_has_more ?? (count($rows) === $this->paginationContext['limit'] && $nextOffset < $total));
|
||||
$lastSort = $rows[0]->_search_last_sort ?? [];
|
||||
$cursorPosition = is_array($lastSort) && count($lastSort) === 2 ? array_values($lastSort) : [$nextOffset];
|
||||
$nextCursor = $hasMore ? $this->cursorCodec->encode(new SearchCursor(
|
||||
sortValues: $cursorPosition,
|
||||
total: $total,
|
||||
queryHash: $this->paginationContext['query_hash'],
|
||||
driver: Search::getCurrentDriver(),
|
||||
indexGeneration: (string) config('search.index_generation', '1'),
|
||||
expiresAt: time() + ((int) config('search.cursor_ttl_minutes', 15) * 60),
|
||||
)) : null;
|
||||
$pagination = ['next_cursor' => $nextCursor, 'has_more' => $hasMore];
|
||||
}
|
||||
|
||||
return $this->presenter->search($rows, $user, $this->buildUserStatsResponse($user), $pagination);
|
||||
}
|
||||
|
||||
private function resolvePaginationOffset(Request $request, int $limit): int|JsonResponse
|
||||
{
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
if (! $request->has('cursor')) {
|
||||
$this->paginationContext = ['enabled' => false, 'offset' => $offset, 'limit' => $limit, 'query_hash' => '', 'cursor' => null];
|
||||
|
||||
return $offset;
|
||||
}
|
||||
if ($offset > 0) {
|
||||
return $this->jsonResponse(['error' => 'cursor cannot be combined with a nonzero offset'], 400);
|
||||
}
|
||||
|
||||
$query = $request->query();
|
||||
unset($query['api_token'], $query['cursor'], $query['offset']);
|
||||
ksort($query);
|
||||
$queryHash = hash('sha256', json_encode($query, JSON_THROW_ON_ERROR));
|
||||
$token = trim((string) $request->input('cursor', ''));
|
||||
$cursor = null;
|
||||
if ($token !== '') {
|
||||
try {
|
||||
$cursor = $this->cursorCodec->decode($token);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->jsonResponse(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
if ($cursor->queryHash !== $queryHash
|
||||
|| $cursor->driver !== Search::getCurrentDriver()
|
||||
|| $cursor->indexGeneration !== (string) config('search.index_generation', '1')) {
|
||||
return $this->jsonResponse(['error' => 'Search cursor does not match this query or index generation.'], 400);
|
||||
}
|
||||
$offset = count($cursor->sortValues) === 1 ? max(0, (int) $cursor->sortValues[0]) : 0;
|
||||
}
|
||||
|
||||
$this->paginationContext = ['enabled' => true, 'offset' => $offset, 'limit' => $limit, 'query_hash' => $queryHash, 'cursor' => $cursor];
|
||||
|
||||
return $offset;
|
||||
}
|
||||
|
||||
private function parseMaxAge(Request $request): int|JsonResponse
|
||||
@@ -224,8 +290,11 @@ class ApiV2Controller extends BasePageController
|
||||
if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) {
|
||||
return $this->jsonResponse(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400);
|
||||
}
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
if (! is_int($maxAge)) {
|
||||
@@ -242,6 +311,7 @@ class ApiV2Controller extends BasePageController
|
||||
'tmdbid' => $tmdbId,
|
||||
'traktid' => $traktId,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'id' => $searchName,
|
||||
'sort' => $sort,
|
||||
@@ -284,8 +354,11 @@ class ApiV2Controller extends BasePageController
|
||||
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
||||
}
|
||||
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
if (! is_int($maxAge)) {
|
||||
@@ -311,6 +384,7 @@ class ApiV2Controller extends BasePageController
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
@@ -361,8 +435,11 @@ class ApiV2Controller extends BasePageController
|
||||
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
||||
}
|
||||
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
if (! is_int($maxAge)) {
|
||||
@@ -388,6 +465,7 @@ class ApiV2Controller extends BasePageController
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
@@ -441,8 +519,11 @@ class ApiV2Controller extends BasePageController
|
||||
return $this->jsonResponse(['error' => 'Specify id (query), anidbid, or anilistid'], 400);
|
||||
}
|
||||
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
if (! is_int($maxAge)) {
|
||||
@@ -460,6 +541,7 @@ class ApiV2Controller extends BasePageController
|
||||
'anidbid' => $anidb,
|
||||
'anilistid' => $anilist,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
@@ -493,7 +575,6 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
$this->recordApiRequest($user, $request);
|
||||
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
@@ -510,12 +591,17 @@ class ApiV2Controller extends BasePageController
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
|
||||
$searchName = $request->input('id');
|
||||
$relData = $this->releaseRowCache->remember('v2', 'search', [
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
@@ -533,7 +619,8 @@ class ApiV2Controller extends BasePageController
|
||||
$catExclusions,
|
||||
$categoryID,
|
||||
$minSize,
|
||||
$sort
|
||||
$sort,
|
||||
$this->paginationContext['cursor'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -598,8 +685,11 @@ class ApiV2Controller extends BasePageController
|
||||
$airDate = str_replace('/', '-', $year[0].'-'.$episode);
|
||||
}
|
||||
|
||||
$offset = $this->queryParameters->offset($request);
|
||||
$limit = $this->queryParameters->limit($request);
|
||||
$offset = $this->resolvePaginationOffset($request, $limit);
|
||||
if (! is_int($offset)) {
|
||||
return $offset;
|
||||
}
|
||||
$categoryID = $this->queryParameters->categories($request);
|
||||
$airDate = $airDate ?? '';
|
||||
$searchName = $request->input('id') ?? '';
|
||||
@@ -610,6 +700,7 @@ class ApiV2Controller extends BasePageController
|
||||
'episode' => $episode,
|
||||
'air_date' => $airDate,
|
||||
'offset' => $offset,
|
||||
'cursor' => $request->input('cursor'),
|
||||
'limit' => $limit,
|
||||
'id' => $searchName,
|
||||
'category' => $categoryID,
|
||||
|
||||
@@ -49,6 +49,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* @property float|null $diff_percent Computed column (difference percentage)
|
||||
* @property int|null $releases_id From raw query alias
|
||||
* @property int|null $_totalcount From subquery count
|
||||
* @property list<int|float|string>|null $_search_last_sort Transient search cursor tuple
|
||||
* @property bool|null $_search_has_more Transient search pagination state
|
||||
*/
|
||||
class Release extends Model
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ final readonly class ApiCapabilitiesService
|
||||
/** @return array<string, mixed> */
|
||||
public function v2(): array
|
||||
{
|
||||
$data = Cache::remember('api_v2_capabilities', 600, function (): array {
|
||||
$data = Cache::remember('api_v2_capabilities_cursor_v1', 600, function (): array {
|
||||
return [
|
||||
'server' => [
|
||||
'title' => config('app.name'),
|
||||
@@ -64,12 +64,12 @@ final readonly class ApiCapabilitiesService
|
||||
],
|
||||
'limits' => ['max' => 100, 'default' => 100],
|
||||
'searching' => [
|
||||
'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,sort'],
|
||||
'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,sort'],
|
||||
'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,sort'],
|
||||
'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'],
|
||||
'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'],
|
||||
'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'],
|
||||
'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,cursor,sort'],
|
||||
'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,cursor,sort'],
|
||||
'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,cursor,sort'],
|
||||
'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,cursor,sort'],
|
||||
'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,cursor,sort'],
|
||||
'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,cursor,sort'],
|
||||
],
|
||||
'categories' => Category::getForApi()
|
||||
->map(static fn (RootCategory $category): array => CategoryData::fromCategory($category)->toArray())
|
||||
|
||||
@@ -24,8 +24,9 @@ final class ApiV2Presenter
|
||||
/**
|
||||
* @param iterable<int, Release|\stdClass> $rows
|
||||
* @param array<string, mixed> $usage
|
||||
* @param array{next_cursor: string|null, has_more: bool}|null $pagination
|
||||
*/
|
||||
public function search(iterable $rows, User $user, array $usage): JsonResponse
|
||||
public function search(iterable $rows, User $user, array $usage, ?array $pagination = null): JsonResponse
|
||||
{
|
||||
$rows = is_array($rows) ? $rows : iterator_to_array($rows, false);
|
||||
$results = [];
|
||||
@@ -33,11 +34,16 @@ final class ApiV2Presenter
|
||||
$results[] = ReleaseData::toArrayFromRelease($row, $user, url('/details').'/', url('/getnzb'));
|
||||
}
|
||||
|
||||
return $this->json(array_merge(
|
||||
$payload = array_merge(
|
||||
['Total' => (int) ($rows[0]->_totalrows ?? 0)],
|
||||
$usage,
|
||||
['results' => $results],
|
||||
));
|
||||
);
|
||||
if ($pagination !== null) {
|
||||
$payload['pagination'] = $pagination;
|
||||
}
|
||||
|
||||
return $this->json($payload);
|
||||
}
|
||||
|
||||
public function details(Release|\stdClass $release, User $user): JsonResponse
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Models\Category;
|
||||
use App\Models\Release;
|
||||
use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -116,7 +118,8 @@ class ReleaseSearchService
|
||||
'try_fuzzy' => true,
|
||||
];
|
||||
|
||||
$filtered = Search::searchReleasesFiltered($criteria, $limit, $offset);
|
||||
$searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset));
|
||||
$filtered = $searchPage->legacy();
|
||||
|
||||
if ($filtered['ids'] !== []) {
|
||||
$ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']);
|
||||
@@ -214,7 +217,7 @@ class ReleaseSearchService
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0, string $orderBy = 'posted_desc'): mixed
|
||||
public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0, string $orderBy = 'posted_desc', ?SearchCursor $cursor = null): mixed
|
||||
{
|
||||
if (config('app.debug')) {
|
||||
Log::debug('ReleaseSearchService::apiSearch called', [
|
||||
@@ -258,7 +261,12 @@ class ReleaseSearchService
|
||||
'try_fuzzy' => true,
|
||||
];
|
||||
|
||||
$filtered = Search::searchReleasesFiltered($criteria, $limit, $offset);
|
||||
$criteria['track_total'] = $cursor === null;
|
||||
$searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset, $cursor));
|
||||
$filtered = $searchPage->legacy();
|
||||
if ($cursor !== null && $filtered['total'] === 0) {
|
||||
$filtered['total'] = $cursor->total;
|
||||
}
|
||||
|
||||
if ($filtered['ids'] === [] && $hasText && config('nntmux.mysql_search_fallback', false) === true) {
|
||||
return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
|
||||
@@ -304,6 +312,8 @@ class ReleaseSearchService
|
||||
|
||||
if ($releases->isNotEmpty()) {
|
||||
$releases[0]->_totalrows = $filtered['total'];
|
||||
$releases[0]->_search_last_sort = $searchPage->lastSortValues;
|
||||
$releases[0]->_search_has_more = $searchPage->hasMore;
|
||||
}
|
||||
|
||||
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Services\Search\Contracts;
|
||||
|
||||
use App\Enums\SecondarySearchIndex;
|
||||
use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchPage;
|
||||
|
||||
/**
|
||||
* Interface for full-text search services (ManticoreSearch, Elasticsearch).
|
||||
@@ -352,6 +354,8 @@ interface SearchServiceInterface
|
||||
*/
|
||||
public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array;
|
||||
|
||||
public function searchReleasePage(ReleaseSearchQuery $query): SearchPage;
|
||||
|
||||
/**
|
||||
* Upsert a metadata document (music, books, games, console, steam, anime).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search\DTO;
|
||||
|
||||
final readonly class ReleaseSearchQuery
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $phrases
|
||||
* @param list<int>|null $categoryIds
|
||||
* @param list<int> $excludedCategoryIds
|
||||
* @param list<int>|null $releaseIds
|
||||
*/
|
||||
public function __construct(
|
||||
public array|string|null $phrases = null,
|
||||
public ?array $categoryIds = null,
|
||||
public array $excludedCategoryIds = [],
|
||||
public ?array $releaseIds = null,
|
||||
public int $minSize = 0,
|
||||
public int $maxSize = 0,
|
||||
public int $maxAgeDays = -1,
|
||||
public int $minDate = 0,
|
||||
public int $maxDate = 0,
|
||||
public ?int $groupId = null,
|
||||
public bool $passwordAllowRar = false,
|
||||
public ?int $passwordStatusMin = null,
|
||||
public string $sortField = 'postdate_ts',
|
||||
public string $sortDirection = 'desc',
|
||||
public bool $tryFuzzy = true,
|
||||
public int $limit = 100,
|
||||
public int $offset = 0,
|
||||
public ?SearchCursor $cursor = null,
|
||||
public bool $trackTotal = true,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $criteria */
|
||||
public static function fromCriteria(array $criteria, int $limit, int $offset = 0, ?SearchCursor $cursor = null): self
|
||||
{
|
||||
return new self(
|
||||
phrases: is_array($criteria['phrases'] ?? null) || is_string($criteria['phrases'] ?? null) ? $criteria['phrases'] : null,
|
||||
categoryIds: is_array($criteria['category_ids'] ?? null) ? array_map('intval', $criteria['category_ids']) : null,
|
||||
excludedCategoryIds: is_array($criteria['excluded_category_ids'] ?? null) ? array_map('intval', $criteria['excluded_category_ids']) : [],
|
||||
releaseIds: is_array($criteria['release_ids'] ?? null) ? array_map('intval', $criteria['release_ids']) : null,
|
||||
minSize: (int) ($criteria['min_size'] ?? 0),
|
||||
maxSize: (int) ($criteria['max_size'] ?? 0),
|
||||
maxAgeDays: (int) ($criteria['max_age_days'] ?? -1),
|
||||
minDate: (int) ($criteria['min_date'] ?? 0),
|
||||
maxDate: (int) ($criteria['max_date'] ?? 0),
|
||||
groupId: isset($criteria['groups_id']) ? (int) $criteria['groups_id'] : null,
|
||||
passwordAllowRar: (bool) ($criteria['password_allow_rar'] ?? false),
|
||||
passwordStatusMin: isset($criteria['password_status_min']) ? (int) $criteria['password_status_min'] : null,
|
||||
sortField: (string) ($criteria['sort_field'] ?? 'postdate_ts'),
|
||||
sortDirection: (string) ($criteria['sort_dir'] ?? 'desc'),
|
||||
tryFuzzy: (bool) ($criteria['try_fuzzy'] ?? true),
|
||||
limit: max(1, $limit),
|
||||
offset: max(0, $offset),
|
||||
cursor: $cursor,
|
||||
trackTotal: (bool) ($criteria['track_total'] ?? true),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function criteria(): array
|
||||
{
|
||||
return [
|
||||
'phrases' => $this->phrases,
|
||||
'category_ids' => $this->categoryIds,
|
||||
'excluded_category_ids' => $this->excludedCategoryIds,
|
||||
'release_ids' => $this->releaseIds,
|
||||
'min_size' => max(0, $this->minSize),
|
||||
'max_size' => max(0, $this->maxSize),
|
||||
'max_age_days' => $this->maxAgeDays,
|
||||
'min_date' => max(0, $this->minDate),
|
||||
'max_date' => max(0, $this->maxDate),
|
||||
'groups_id' => $this->groupId,
|
||||
'password_allow_rar' => $this->passwordAllowRar,
|
||||
'password_status_min' => $this->passwordStatusMin,
|
||||
'sort_field' => $this->normalizedSortField(),
|
||||
'sort_dir' => $this->normalizedSortDirection(),
|
||||
'try_fuzzy' => $this->tryFuzzy,
|
||||
'cursor_sort' => $this->cursor?->sortValues,
|
||||
'track_total' => $this->trackTotal,
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizedSortField(): string
|
||||
{
|
||||
return in_array($this->sortField, ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id'], true)
|
||||
? $this->sortField
|
||||
: 'postdate_ts';
|
||||
}
|
||||
|
||||
public function normalizedSortDirection(): string
|
||||
{
|
||||
return strtolower($this->sortDirection) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search\DTO;
|
||||
|
||||
final readonly class SearchCursor
|
||||
{
|
||||
/** @param list<int|float|string> $sortValues */
|
||||
public function __construct(
|
||||
public array $sortValues,
|
||||
public int $total,
|
||||
public string $queryHash,
|
||||
public string $driver,
|
||||
public string $indexGeneration,
|
||||
public int $expiresAt,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search\DTO;
|
||||
|
||||
final readonly class SearchPage
|
||||
{
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
* @param list<int|float|string> $lastSortValues
|
||||
*/
|
||||
public function __construct(
|
||||
public array $ids,
|
||||
public int $total,
|
||||
public bool $fuzzy,
|
||||
public string $driver,
|
||||
public bool $available = true,
|
||||
public float $durationMs = 0.0,
|
||||
public array $lastSortValues = [],
|
||||
public bool $hasMore = false,
|
||||
) {}
|
||||
|
||||
/** @return array{ids: list<int>, total: int, fuzzy: bool} */
|
||||
public function legacy(): array
|
||||
{
|
||||
return ['ids' => $this->ids, 'total' => $this->total, 'fuzzy' => $this->fuzzy];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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;
|
||||
@@ -93,6 +95,23 @@ class ElasticSearchDriver implements SearchDriverInterface
|
||||
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'])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an Elasticsearch client with proper cURL configuration.
|
||||
*
|
||||
@@ -2859,7 +2878,7 @@ class ElasticSearchDriver implements SearchDriverInterface
|
||||
public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array
|
||||
{
|
||||
if (! $this->isElasticsearchAvailable()) {
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => false];
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false];
|
||||
}
|
||||
|
||||
$phrases = $criteria['phrases'] ?? null;
|
||||
@@ -2936,38 +2955,54 @@ class ElasticSearchDriver implements SearchDriverInterface
|
||||
|
||||
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' => [
|
||||
'query' => $query,
|
||||
'sort' => [
|
||||
[$sortField => ['order' => $order]],
|
||||
['id' => ['order' => $order]],
|
||||
],
|
||||
'from' => max(0, $offset),
|
||||
'size' => max(1, min($limit, self::MAX_RESULTS)),
|
||||
'track_total_hits' => true,
|
||||
'_source' => false,
|
||||
],
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$ids = [];
|
||||
foreach ($response['hits']['hits'] ?? [] as $hit) {
|
||||
$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];
|
||||
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];
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false];
|
||||
}
|
||||
};
|
||||
|
||||
if ($hasText) {
|
||||
$first = $run(false);
|
||||
if ($first['ids'] !== [] || ! $tryFuzzy || ! $this->isFuzzyEnabled()) {
|
||||
if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled()) {
|
||||
return $first;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ 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\ManticoreClientFactory;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use App\Support\SecondaryIndexDocuments;
|
||||
@@ -31,6 +33,12 @@ use Manticoresearch\Search;
|
||||
*/
|
||||
class ManticoreSearchDriver implements SearchDriverInterface
|
||||
{
|
||||
private const AVAILABILITY_CHECK_CACHE_TTL = 30;
|
||||
|
||||
private static ?bool $availabilityCache = null;
|
||||
|
||||
private static ?int $availabilityCacheTime = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
@@ -74,11 +82,22 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
$now = time();
|
||||
if (self::$availabilityCache !== null && self::$availabilityCacheTime !== null
|
||||
&& ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) {
|
||||
return self::$availabilityCache;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $this->manticoreSearch->nodes()->status();
|
||||
|
||||
return ! empty($status);
|
||||
self::$availabilityCache = ! empty($status);
|
||||
self::$availabilityCacheTime = $now;
|
||||
|
||||
return self::$availabilityCache;
|
||||
} catch (\Throwable $e) {
|
||||
self::$availabilityCache = false;
|
||||
self::$availabilityCacheTime = $now;
|
||||
if (config('app.debug')) {
|
||||
Log::debug('ManticoreSearch not available: '.$e->getMessage());
|
||||
}
|
||||
@@ -87,6 +106,23 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function searchReleasePage(ReleaseSearchQuery $query): SearchPage
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$result = $this->searchReleasesFiltered($query->criteria(), $query->limit, $query->offset);
|
||||
|
||||
return new SearchPage(
|
||||
ids: array_map(static fn (int|string $id): int => (int) $id, $result['ids']),
|
||||
total: (int) $result['total'],
|
||||
fuzzy: (bool) $result['fuzzy'],
|
||||
driver: $this->getDriverName(),
|
||||
available: (bool) ($result['available'] ?? true),
|
||||
durationMs: (hrtime(true) - $startedAt) / 1_000_000,
|
||||
lastSortValues: $result['last_sort'] ?? [],
|
||||
hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if autocomplete is enabled.
|
||||
*/
|
||||
@@ -2467,7 +2503,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array
|
||||
{
|
||||
if (! $this->isAvailable()) {
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => false];
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false];
|
||||
}
|
||||
|
||||
$phrases = $criteria['phrases'] ?? null;
|
||||
@@ -2539,7 +2575,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
'criteria' => $criteria,
|
||||
]);
|
||||
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy];
|
||||
return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
@@ -2551,12 +2587,17 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
'ids' => $ids,
|
||||
'total' => (int) $results->getTotal(),
|
||||
'fuzzy' => $useFuzzy,
|
||||
'available' => true,
|
||||
// Manticore uses the compatible offset cursor until the PHP client
|
||||
// supports a compound search-after predicate without raw SQL.
|
||||
'last_sort' => [],
|
||||
'has_more' => $offset + count($ids) < (int) $results->getTotal(),
|
||||
];
|
||||
};
|
||||
|
||||
if ($hasText) {
|
||||
$first = $execute(false);
|
||||
if ($first['ids'] !== [] || ! $tryFuzzy || ! $this->isFuzzyEnabled() || self::queryHasNegation($phrases)) {
|
||||
if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled() || self::queryHasNegation($phrases)) {
|
||||
return $first;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search;
|
||||
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class SearchCursorCodec
|
||||
{
|
||||
public function __construct(private Encrypter $encrypter) {}
|
||||
|
||||
public function encode(SearchCursor $cursor): string
|
||||
{
|
||||
return $this->encrypter->encryptString(json_encode([
|
||||
'sort' => $cursor->sortValues,
|
||||
'total' => $cursor->total,
|
||||
'query' => $cursor->queryHash,
|
||||
'driver' => $cursor->driver,
|
||||
'generation' => $cursor->indexGeneration,
|
||||
'expires' => $cursor->expiresAt,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function decode(string $token): SearchCursor
|
||||
{
|
||||
try {
|
||||
$payload = json_decode($this->encrypter->decryptString($token), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (DecryptException|\JsonException $e) {
|
||||
throw new InvalidArgumentException('Invalid search cursor.', previous: $e);
|
||||
}
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['sort'] ?? null) || (int) ($payload['expires'] ?? 0) < time()) {
|
||||
throw new InvalidArgumentException('Invalid or expired search cursor.');
|
||||
}
|
||||
|
||||
return new SearchCursor(
|
||||
sortValues: array_values($payload['sort']),
|
||||
total: (int) ($payload['total'] ?? 0),
|
||||
queryHash: (string) ($payload['query'] ?? ''),
|
||||
driver: (string) ($payload['driver'] ?? ''),
|
||||
indexGeneration: (string) ($payload['generation'] ?? ''),
|
||||
expiresAt: (int) $payload['expires'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ use App\Services\Search\Contracts\SearchDriverInterface;
|
||||
use App\Services\Search\Contracts\SearchServiceInterface;
|
||||
use App\Services\Search\Drivers\ElasticSearchDriver;
|
||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||
use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchPage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Manager;
|
||||
|
||||
/**
|
||||
@@ -510,6 +513,25 @@ class SearchService extends Manager implements SearchServiceInterface
|
||||
return $this->driver()->searchReleasesFiltered($criteria, $limit, $offset);
|
||||
}
|
||||
|
||||
public function searchReleasePage(ReleaseSearchQuery $query): SearchPage
|
||||
{
|
||||
$page = $this->driver()->searchReleasePage($query);
|
||||
Log::debug('Release search completed', [
|
||||
'driver' => $page->driver,
|
||||
'available' => $page->available,
|
||||
'duration_ms' => round($page->durationMs, 2),
|
||||
'result_count' => count($page->ids),
|
||||
'total' => $page->total,
|
||||
'fuzzy' => $page->fuzzy,
|
||||
'pagination_mode' => $query->cursor === null ? 'offset' : 'cursor',
|
||||
'offset' => $query->offset,
|
||||
'limit' => $query->limit,
|
||||
'track_total' => $query->trackTotal,
|
||||
]);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void
|
||||
{
|
||||
$this->driver()->insertSecondary($index, $id, $document);
|
||||
|
||||
@@ -13,6 +13,10 @@ return [
|
||||
*/
|
||||
'default' => env('SEARCH_DRIVER', 'manticore'),
|
||||
|
||||
'index_generation' => env('SEARCH_INDEX_GENERATION', '1'),
|
||||
|
||||
'cursor_ttl_minutes' => (int) env('SEARCH_CURSOR_TTL_MINUTES', 15),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Search Driver Configurations
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Search performance and index maintenance
|
||||
|
||||
The website, Newznab API, and API v2 share the release search contract in
|
||||
`app/Services/Search/DTO`. Offset responses keep exact totals. API v2 also accepts an
|
||||
empty `cursor` parameter to start cursor-mode pagination and returns the next opaque,
|
||||
signed cursor in `pagination.next_cursor`.
|
||||
|
||||
## Baseline and validation
|
||||
|
||||
Capture warm- and cold-cache measurements for both `SEARCH_DRIVER=manticore` and
|
||||
`SEARCH_DRIVER=elasticsearch`. Record p50/p95 response time, search backend duration,
|
||||
SQL query count, peak memory, result count, offset, and whether fuzzy fallback ran.
|
||||
Use the same query/category/sort matrix before and after a change. Include offsets 0,
|
||||
1,000, and 5,000 plus API v2 cursor traversal.
|
||||
|
||||
Do not set a performance target until the baseline is recorded. A normal successful
|
||||
exact search should make one search request; fuzzy search may make a second request
|
||||
only after an exact zero-result response.
|
||||
|
||||
## Maintenance rebuild
|
||||
|
||||
Search schema changes require a maintenance window. Before rebuilding, enable
|
||||
`nntmux.mysql_search_fallback` if bounded database fallback is desired while indexes
|
||||
are empty.
|
||||
|
||||
1. Run `php artisan tmux:stop` and pause queue workers that update search indexes.
|
||||
2. Recreate Manticore indexes with `php artisan manticore:create-indexes --drop`, or
|
||||
recreate Elasticsearch indexes with the project's Elasticsearch index command.
|
||||
3. Repopulate enabled indexes with `php artisan nntmux:populate --manticore --all` or
|
||||
the equivalent Elasticsearch population flags.
|
||||
4. Compare source and index document counts and run representative filtered searches.
|
||||
5. Increment `SEARCH_INDEX_GENERATION` to invalidate outstanding API v2 cursors.
|
||||
6. Restart queue workers and run `php artisan tmux:start`.
|
||||
|
||||
If count or query validation fails, leave processing stopped, restore/rebuild the
|
||||
previous schema, and keep database fallback enabled until indexes are healthy.
|
||||
@@ -6,6 +6,8 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchPage;
|
||||
use App\Services\Search\SearchService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -140,19 +142,17 @@ final class MovieSearchApiTest extends TestCase
|
||||
{
|
||||
$mock = Mockery::mock(SearchService::class, [$this->app]);
|
||||
$mock->shouldReceive('isAvailable')->andReturn(true);
|
||||
$mock->shouldReceive('searchReleasesFiltered')
|
||||
$mock->shouldReceive('searchReleasePage')
|
||||
->once()
|
||||
->withArgs(function (array $criteria, int $limit, int $offset): bool {
|
||||
->withArgs(function (ReleaseSearchQuery $query): bool {
|
||||
$criteria = $query->criteria();
|
||||
|
||||
return ($criteria['phrases'] ?? null) === 'Resurrection 2025'
|
||||
&& ($criteria['try_fuzzy'] ?? null) === true
|
||||
&& $limit === 100
|
||||
&& $offset === 0;
|
||||
&& $query->limit === 100
|
||||
&& $query->offset === 0;
|
||||
})
|
||||
->andReturn([
|
||||
'ids' => [],
|
||||
'total' => 0,
|
||||
'fuzzy' => false,
|
||||
]);
|
||||
->andReturn(new SearchPage([], 0, false, 'manticore'));
|
||||
|
||||
$this->app->instance(SearchService::class, $mock);
|
||||
|
||||
|
||||
@@ -54,4 +54,17 @@ final class ElasticSearchQueryTest extends TestCase
|
||||
'Expected indexSearch/indexSearchApi/indexSearchTMA to use RELEASE_TEXT_FIELDS.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtered_release_search_supports_search_after_without_loading_source(): void
|
||||
{
|
||||
$driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php');
|
||||
|
||||
$this->assertIsString($driverSource);
|
||||
$methodSource = strstr($driverSource, 'public function searchReleasesFiltered');
|
||||
$this->assertIsString($methodSource);
|
||||
$this->assertStringContainsString("\$body['search_after']", $methodSource);
|
||||
$this->assertStringContainsString("'_source' => false", $methodSource);
|
||||
$this->assertStringContainsString("'track_total_hits' => (bool)", $methodSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
use App\Services\Search\SearchCursorCodec;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SearchCursorCodecTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_round_trips_an_opaque_signed_cursor(): void
|
||||
{
|
||||
$codec = new SearchCursorCodec(new Encrypter(random_bytes(32), 'AES-256-CBC'));
|
||||
$cursor = new SearchCursor([1710000000, 42], 250, 'query-hash', 'elasticsearch', '7', time() + 60);
|
||||
|
||||
$decoded = $codec->decode($codec->encode($cursor));
|
||||
|
||||
$this->assertSame($cursor->sortValues, $decoded->sortValues);
|
||||
$this->assertSame(250, $decoded->total);
|
||||
$this->assertSame('query-hash', $decoded->queryHash);
|
||||
$this->assertSame('elasticsearch', $decoded->driver);
|
||||
$this->assertSame('7', $decoded->indexGeneration);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_tampered_and_expired_cursors(): void
|
||||
{
|
||||
$codec = new SearchCursorCodec(new Encrypter(random_bytes(32), 'AES-256-CBC'));
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$codec->decode('tampered');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_an_expired_cursor(): void
|
||||
{
|
||||
$codec = new SearchCursorCodec(new Encrypter(random_bytes(32), 'AES-256-CBC'));
|
||||
$token = $codec->encode(new SearchCursor([100], 10, 'hash', 'manticore', '1', time() - 1));
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$codec->decode($token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user