Files
newznab-tmux-NNTmux/app/Services/Releases/ReleaseSearchService.php
T
2026-08-02 22:49:50 +02:00

2185 lines
84 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\Releases;
use App\Enums\SecondarySearchIndex;
use App\Facades\Search;
use App\Models\AnidbInfo;
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 App\Support\ReleaseSearchIndexDocument;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Service for searching releases.
*/
class ReleaseSearchService
{
private const CACHE_VERSION_KEY = 'releases:cache_version';
private const SEARCH_INDEX_BUFFER_PAGES = 10;
private const SEARCH_INDEX_MIN_CANDIDATES = 250;
private const SEARCH_INDEX_MAX_CANDIDATES = 2000;
// RAR/ZIP Password indicator.
public const PASSWD_NONE = 0;
public const PASSWD_RAR = 1;
public function __construct() {}
/**
* Function for searching on the site (by subject, searchname or advanced).
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCats
* @param array<string, mixed> $orderBy
* @param array<string, mixed> $searchArr
* @return array|Collection|mixed
*/
public function search(
array $searchArr,
mixed $groupName,
mixed $sizeFrom,
mixed $sizeTo,
mixed $daysNew,
mixed $daysOld,
int $offset = 0,
int $limit = 1000,
array|string $orderBy = '',
int $maxAge = -1,
array $excludedCats = [],
string $type = 'basic',
array $cat = [-1],
int $minSize = 0
): mixed {
if (config('app.debug')) {
Log::debug('ReleaseSearchService::search called', [
'searchArr' => $searchArr,
'limit' => $limit,
'type' => $type,
]);
}
// Filter out -1 values and empty strings
$searchFields = Arr::where($searchArr, static function ($value) {
return $value !== -1 && $value !== '' && $value !== null;
});
if ($searchFields === []) {
return collect();
}
$categoryIds = $this->resolveSearchCategoryIds($cat, $excludedCats);
if ($categoryIds === []) {
return collect();
}
$orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy);
if (Search::isAvailable()) {
$groupId = null;
if ((int) $groupName !== -1) {
$resolved = UsenetGroup::getIDByName((string) $groupName);
if ($resolved) {
$groupId = (int) $resolved;
}
}
[$sizeMinFromRange, $sizeMaxFromRange] = $this->resolveSizeRangeBounds($sizeFrom, $sizeTo);
$minSizeCriteria = max((int) $minSize, $sizeMinFromRange);
$maxSizeCriteria = $sizeMaxFromRange;
[$minDateCriteria, $maxDateCriteria] = $this->resolvePostdateBounds($daysNew, $daysOld);
$criteria = [
'phrases' => $searchFields,
'category_ids' => $categoryIds,
'excluded_category_ids' => $excludedCats,
'min_size' => $minSizeCriteria,
'max_size' => $maxSizeCriteria,
'max_age_days' => $maxAge,
'min_date' => $minDateCriteria,
'max_date' => $maxDateCriteria,
'groups_id' => $groupId,
'password_allow_rar' => $this->passwordAllowRar(),
'sort_field' => $this->browseOrderToIndexSortField((string) $orderBy[0]),
'sort_dir' => $orderBy[1] ?? 'desc',
'try_fuzzy' => true,
];
$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']);
$idList = implode(',', $ids);
$baseSql = $this->buildSearchBaseSql(sprintf('WHERE r.id IN (%s)', $idList));
$sql = sprintf('SELECT * FROM (%s) r ORDER BY FIELD(r.id, %s)', $baseSql, $idList);
$cacheKey = md5($this->getCacheVersion().$sql);
$cachedReleases = Cache::get($cacheKey);
if ($cachedReleases !== null) {
return $cachedReleases;
}
$releases = Release::fromQuery($sql);
if ($releases->isNotEmpty()) {
$releases[0]->_totalrows = (int) $filtered['total'];
}
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
if (config('nntmux.mysql_search_fallback', false) !== true) {
return collect();
}
}
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
$searchResult = $this->performIndexSearch($searchFields, $searchLimit);
if (config('app.debug')) {
Log::debug('ReleaseSearchService::search after performIndexSearch', [
'result_count' => count($searchResult),
]);
}
if (count($searchResult) === 0) {
return collect();
}
// Build WHERE clause
$whereSql = $this->buildSearchWhereClause(
$searchResult,
$groupName,
$sizeFrom,
$sizeTo,
$daysNew,
$daysOld,
$maxAge,
$excludedCats,
$cat,
$minSize
);
// Build base SQL
$baseSql = $this->buildSearchBaseSql($whereSql);
// Build final SQL with pagination
$sql = sprintf(
'SELECT * FROM (%s) r ORDER BY r.%s %s LIMIT %d OFFSET %d',
$baseSql,
$orderBy[0], // @phpstan-ignore offsetAccess.notFound
$orderBy[1], // @phpstan-ignore offsetAccess.notFound
$limit,
$offset
);
// Check cache
$cacheKey = md5($this->getCacheVersion().$sql);
$releases = Cache::get($cacheKey);
if ($releases !== null) {
return $releases;
}
// Execute query
$releases = Release::fromQuery($sql);
// Add total count for pagination
if ($releases->isNotEmpty()) {
$releases[0]->_totalrows = $this->getPagerCount($baseSql);
}
// Cache results
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
/**
* Search function for API.
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @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', ?SearchCursor $cursor = null): mixed
{
if (config('app.debug')) {
Log::debug('ReleaseSearchService::apiSearch called', [
'searchName' => $searchName,
'groupName' => $groupName,
'offset' => $offset,
'limit' => $limit,
]);
}
$hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null;
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
if (Search::isAvailable()) {
$groupId = null;
if ((int) $groupName !== -1) {
$resolved = UsenetGroup::getIDByName((string) $groupName);
if ($resolved) {
$groupId = (int) $resolved;
}
}
$categoryIdsRaw = Category::getCategorySearch($cat, null, true);
$categoryIds = null;
if (is_array($categoryIdsRaw)) {
$categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw);
} elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
$categoryIds = [(int) $categoryIdsRaw];
}
$criteria = [
'phrases' => $hasText ? $searchName : null,
'category_ids' => $categoryIds,
'excluded_category_ids' => $excludedCats,
'min_size' => $minSize,
'max_age_days' => $maxAge,
'groups_id' => $groupId,
'password_allow_rar' => $this->passwordAllowRar(),
'sort_field' => $this->browseOrderToIndexSortField($orderField),
'sort_dir' => $orderDir,
'try_fuzzy' => true,
'include_documents' => true,
];
$criteria['track_total'] = $cursor === null;
$searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset, $cursor));
$filtered = $searchPage->legacy();
if ($searchPage->documents !== []) {
$releases = collect(array_map(function (array $document): object {
$row = ReleaseSearchIndexDocument::toReleaseRow($document);
$row['title'] = $row['episode_title'];
$row['firstaired'] = $row['firstaired_ts'] > 0
? date('Y-m-d H:i:s', $row['firstaired_ts'])
: null;
return (object) $row;
}, $searchPage->documents));
if ($releases->isNotEmpty()) {
$releases[0]->_totalrows = (int) $filtered['total'];
$releases[0]->_search_last_sort = $searchPage->lastSortValues;
$releases[0]->_search_has_more = $searchPage->hasMore;
}
return $releases;
}
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);
}
if ($filtered['ids'] === []) {
return collect();
}
$ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']);
$idList = implode(',', $ids);
$whereSql = 'WHERE r.id IN ('.$idList.')';
$sql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name,
m.imdbid, m.tmdbid, m.traktid,
v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
tve.firstaired, tve.title, tve.series, tve.episode
FROM releases r
INNER JOIN categories c ON c.id = r.categories_id
INNER JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
%s
ORDER BY r.%s %s",
$whereSql,
$orderField,
$orderDir
);
$cacheKey = md5($this->getCacheVersion().$sql);
$cachedReleases = Cache::get($cacheKey);
if ($cachedReleases !== null) {
return $cachedReleases;
}
$releases = Release::fromQuery($sql);
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'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
}
/**
* Original API search path (SQL filters + optional index ID list from fuzzy only).
*
* @param array<int|string, mixed> $cat
*/
private function apiSearchLegacyMysql(mixed $searchName, mixed $groupName, int $offset, int $limit, int $maxAge, array $excludedCats, array $cat, int $minSize, string $orderBy = 'posted_desc'): mixed
{
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
$searchResult = [];
$hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null;
if ($hasText) {
$fuzzyResult = Search::searchReleasesWithFuzzy($searchName, $searchLimit);
$searchResult = $fuzzyResult['ids'] ?? [];
if (config('app.debug') && ($fuzzyResult['fuzzy'] ?? false)) {
Log::debug('apiSearch: Using fuzzy search results');
}
if ($searchResult === [] && config('nntmux.mysql_search_fallback', false) === true) {
if (config('app.debug')) {
Log::debug('apiSearch: Falling back to MySQL search');
}
$searchResult = $this->performMySQLSearch(['searchname' => $searchName], $searchLimit);
}
if ($searchResult === []) {
if (config('app.debug')) {
Log::debug('apiSearch: No results from any search engine');
}
return collect();
}
}
$conditions = [
sprintf('r.passwordstatus %s', $this->showPasswords()),
];
if ($maxAge > 0) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
}
if ((int) $groupName !== -1) {
$groupId = UsenetGroup::getIDByName($groupName);
if ($groupId) {
$conditions[] = sprintf('r.groups_id = %d', $groupId);
}
}
$catQuery = Category::getCategorySearch($cat);
$catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery));
if (! empty($catQuery) && $catQuery !== '1=1') {
$conditions[] = $catQuery;
}
if ($excludedCats !== []) {
$conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats)));
}
if ($searchResult !== []) {
$conditions[] = sprintf('r.id IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $searchResult)));
}
if ($minSize > 0) {
$conditions[] = sprintf('r.size >= %d', $minSize);
}
$whereSql = 'WHERE '.implode(' AND ', $conditions);
$sql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name,
m.imdbid, m.tmdbid, m.traktid,
v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
tve.firstaired, tve.title, tve.series, tve.episode
FROM releases r
INNER JOIN categories c ON c.id = r.categories_id
INNER JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
%s
ORDER BY r.%s %s
LIMIT %d OFFSET %d",
$whereSql,
$orderField,
$orderDir,
$limit,
$offset
);
$cacheKey = md5($this->getCacheVersion().$sql);
$cachedReleases = Cache::get($cacheKey);
if ($cachedReleases !== null) {
return $cachedReleases;
}
$releases = Release::fromQuery($sql);
if ($releases->isNotEmpty()) {
$countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
$countResult = Release::fromQuery($countSql);
$releases[0]->_totalrows = $countResult[0]->count ?? 0;
}
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
/**
* API: music releases linked to rows from the music metadata index.
*
* @param array<int|string, mixed> $cat
* @param array<int, int> $excludedCats
*/
public function apiMusicSearch(
string $q,
mixed $groupName,
int $offset,
int $limit,
int $maxAge,
array $excludedCats,
array $cat,
int $minSize,
string $orderBy = 'posted_desc'
): mixed {
$q = trim($q);
if ($q === '' || ! Search::isAvailable()) {
return collect();
}
$musicInfoIds = Search::searchSecondary(SecondarySearchIndex::Music, $q, 2000)['id'];
return $this->apiSearchByMetadataForeignKey($musicInfoIds, 'musicinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
}
/**
* API: book releases linked to rows from the books metadata index.
*
* @param array<int|string, mixed> $cat
* @param array<int, int> $excludedCats
*/
public function apiBookSearch(
string $q,
mixed $groupName,
int $offset,
int $limit,
int $maxAge,
array $excludedCats,
array $cat,
int $minSize,
string $orderBy = 'posted_desc'
): mixed {
$q = trim($q);
if ($q === '' || ! Search::isAvailable()) {
return collect();
}
$bookIds = Search::searchSecondary(SecondarySearchIndex::Books, $q, 2000)['id'];
return $this->apiSearchByMetadataForeignKey($bookIds, 'bookinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy);
}
/**
* @param list<int> $metadataIds
* @param array<int|string, mixed> $cat
* @param array<int, int> $excludedCats
*/
private function apiSearchByMetadataForeignKey(
array $metadataIds,
string $column,
mixed $groupName,
int $offset,
int $limit,
int $maxAge,
array $excludedCats,
array $cat,
int $minSize,
string $orderBy = 'posted_desc'
): mixed {
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
if ($metadataIds === []) {
return collect();
}
if (! in_array($column, ['musicinfo_id', 'bookinfo_id'], true)) {
return collect();
}
$conditions = [
sprintf('r.passwordstatus %s', $this->showPasswords()),
sprintf('r.%s IN (%s)', $column, implode(',', array_map(static fn (int $id): int => $id, $metadataIds))),
];
if ($maxAge > 0) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
}
if ((int) $groupName !== -1) {
$groupId = UsenetGroup::getIDByName($groupName);
if ($groupId) {
$conditions[] = sprintf('r.groups_id = %d', $groupId);
}
}
$catQuery = Category::getCategorySearch($cat);
$catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery));
if (! empty($catQuery) && $catQuery !== '1=1') {
$conditions[] = $catQuery;
}
if ($excludedCats !== []) {
$conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats)));
}
if ($minSize > 0) {
$conditions[] = sprintf('r.size >= %d', $minSize);
}
$whereSql = 'WHERE '.implode(' AND ', $conditions);
$sql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name,
m.imdbid, m.tmdbid, m.traktid,
v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
tve.firstaired, tve.title, tve.series, tve.episode
FROM releases r
INNER JOIN categories c ON c.id = r.categories_id
INNER JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
%s
ORDER BY r.%s %s
LIMIT %d OFFSET %d",
$whereSql,
$orderField,
$orderDir,
$limit,
$offset
);
$cacheKey = md5($this->getCacheVersion().$sql);
$cachedReleases = Cache::get($cacheKey);
if ($cachedReleases !== null) {
return $cachedReleases;
}
$releases = Release::fromQuery($sql);
if ($releases->isNotEmpty()) {
$countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
$countResult = Release::fromQuery($countSql);
$releases[0]->_totalrows = $countResult[0]->count ?? 0;
}
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
/**
* Search for TV shows via API.
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCategories
* @param array<string, mixed> $siteIdArr
* @return array|Collection|\Illuminate\Support\Collection|mixed
*/
public function tvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
{
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
$hasStrictTvSelector = $this->hasTvLookupIdentifiers($siteIdArr);
$shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0);
$rawCacheKey = md5(serialize(func_get_args()).'tvSearch');
$cacheKey = null;
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
if ($shouldCache) {
$cacheKey = md5($this->getCacheVersion().'tvSearch:v2:'.$rawCacheKey);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
}
$conditions = [
sprintf('r.passwordstatus %s', $this->showPasswords()),
];
$videoJoinCondition = '';
$episodeJoinCondition = '';
$needsEpisodeJoin = false;
$needsDatabaseLookup = false;
// OPTIMIZATION: Try to find releases using search index external IDs first
$externalIds = [];
if (! empty($siteIdArr)) {
foreach ($siteIdArr as $column => $id) {
$hasValue = $column === 'imdb'
? ($id !== null && $id !== '' && imdb_id_is_valid((string) $id))
: ($id > 0);
if ($hasValue && $column !== 'id') {
// Map column names to search index field names
$fieldName = match ($column) {
'tvdb' => 'tvdb',
'trakt' => 'traktid', // Note: in releases index we use traktid
'tvmaze' => 'tvmaze',
'tvrage' => 'tvrage',
'imdb' => 'imdbid',
'tmdb' => 'tmdbid',
default => null,
};
if ($fieldName) {
$externalIds[$fieldName] = $column === 'imdb' ? (string) $id : (int) $id;
}
}
}
}
// Try to get releases directly from search index using external IDs
$searchResult = [];
if (! empty($externalIds)) {
$searchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);
if (config('app.debug') && ! empty($searchResult)) {
Log::debug('tvSearch: Found releases via search index by external IDs', [
'externalIds' => $externalIds,
'count' => count($searchResult),
]);
}
}
// If search index didn't return results, fall back to database lookup
if (empty($searchResult) && $hasStrictTvSelector) {
$siteConditions = [];
foreach ($siteIdArr as $column => $id) {
if ($id > 0) {
$siteConditions[] = sprintf('v.%s = %d', $column, (int) $id);
}
}
if (! empty($siteConditions)) {
$needsDatabaseLookup = true;
$siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0;
$seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : '';
$episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : '';
$airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '';
$lookupSql = sprintf(
'SELECT v.id AS video_id, tve.id AS episode_id FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s',
implode(' OR ', $siteConditions),
$seriesFilter,
$episodeFilter,
$airDateFilter
);
$results = Release::fromQuery($lookupSql);
if ($results->isEmpty()) {
$this->logStrictExternalLookupMiss('tvSearch database lookup', $siteIdArr, [
'series' => $series,
'episode' => $episode,
'airDate' => $airDate,
]);
return collect();
}
$videoIds = $results->pluck('video_id')->filter()->unique()->toArray();
$episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray();
if (! empty($videoIds)) {
$conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds)));
$videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds)));
}
if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) {
$conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds)));
$episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds)));
$needsEpisodeJoin = true;
}
if ($siteUsesVideoIdOnly) {
$needsEpisodeJoin = false;
}
}
}
// If search index found releases via external IDs, add them to conditions
$hasSearchResultFromExternalIds = ! empty($searchResult);
if ($hasSearchResultFromExternalIds) {
if (Search::isAvailable()) {
$searchResult = $this->intersectReleaseIdsWithSearchFilters(
$searchResult,
$cat,
'tv',
$maxAge,
$minSize,
$excludedCategories
);
if ($searchResult === []) {
return collect();
}
}
$conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
$episodePredicates = $this->buildEpisodeJoinPredicates($series, $episode, $airDate);
if ($episodePredicates !== []) {
foreach ($episodePredicates as $predicate) {
$conditions[] = $predicate;
}
$needsEpisodeJoin = true;
}
}
// Only do name-based search if we don't already have results from external IDs
if (! $hasSearchResultFromExternalIds && ! $hasStrictTvSelector && ! empty($name)) {
$searchName = $name;
$hasValidSiteIds = false;
foreach ($siteIdArr as $column => $id) {
if ($id > 0) {
$hasValidSiteIds = true;
break;
}
}
if (! $hasValidSiteIds) {
// Build search name with season/episode for the full-text search
if (! empty($series) && (int) $series < 1900) {
$searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
$seriesNum = (int) preg_replace('/^s0*/i', '', $series);
if (! empty($episode) && ! str_contains($episode, '/')) {
$searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
$episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
}
} elseif (! empty($airDate)) {
$searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
}
}
$searchResult = Search::searchReleases(['searchname' => $searchName], $searchLimit);
// Fall back to MySQL if search engine failed (only if enabled)
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $searchName], $searchLimit);
}
if (empty($searchResult)) {
return collect();
}
if (Search::isAvailable()) {
$searchResult = $this->intersectReleaseIdsWithSearchFilters(
$searchResult,
$cat,
'tv',
$maxAge,
$minSize,
$excludedCategories
);
if ($searchResult === []) {
return collect();
}
}
$conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
// Try to add episode conditions if season/episode data is provided and no valid site IDs
// This will filter results to only those with matching episode data in tv_episodes table
// If this results in no matches, we'll fall back to results without episode conditions
if (! $hasValidSiteIds && (! empty($series) || ! empty($airDate))) {
$episodeConditions = $this->buildEpisodeJoinPredicates($series, $episode, $airDate);
if (! empty($episodeConditions)) {
// Check if any of the found releases have matching episode data
$checkSql = sprintf(
'SELECT r.id FROM releases r INNER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id WHERE r.id IN (%s) AND %s LIMIT 1',
implode(',', array_map('intval', $searchResult)),
implode(' AND ', $episodeConditions)
);
$hasEpisodeMatches = Release::fromQuery($checkSql);
if ($hasEpisodeMatches->isNotEmpty()) {
// Some releases have matching episode data, add the conditions
foreach ($episodeConditions as $cond) {
$conditions[] = $cond;
}
$needsEpisodeJoin = true;
}
// If no matches with episode data, don't add episode conditions
// The search will return results based on searchname match only
}
}
}
$applySqlCategorySizeExcluded = $needsDatabaseLookup || empty($searchResult) || ! Search::isAvailable();
$catQuery = Category::getCategorySearch($cat, 'tv');
$catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
if ($applySqlCategorySizeExcluded) {
if (! empty($catQuery) && $catQuery !== '1=1') {
$conditions[] = $catQuery;
}
if ($maxAge > 0) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
}
if ($minSize > 0) {
$conditions[] = sprintf('r.size >= %d', $minSize);
}
if (! empty($excludedCategories)) {
$conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
}
}
$whereSql = 'WHERE '.implode(' AND ', $conditions);
$joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT';
// Optimized select list only fields required by XML (extended) and transformers
$baseSql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
r.adddate, r.videos_id, r.tv_episodes_id,
v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
tve.series, tve.episode, tve.firstaired,
cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name
FROM releases r
INNER JOIN categories c ON c.id = r.categories_id
INNER JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s
%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s
LEFT JOIN usenet_groups g ON g.id = r.groups_id
%s",
$videoJoinCondition,
$joinType,
$episodeJoinCondition,
$whereSql
);
$limitClause = '';
if ($limit > 0) {
$limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset);
}
$sql = sprintf('%s ORDER BY r.%s %s%s', $baseSql, $orderField, $orderDir, $limitClause);
$releases = Release::fromQuery($sql);
if ($hasStrictTvSelector && $releases->isEmpty()) {
$this->logStrictExternalLookupMiss('tvSearch release lookup', $siteIdArr, [
'series' => $series,
'episode' => $episode,
'airDate' => $airDate,
]);
}
if ($releases->isNotEmpty()) {
$countSql = sprintf(
'SELECT COUNT(*) as count FROM releases r %s %s %s',
(! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''),
($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''),
$whereSql
);
$countResult = Release::fromQuery($countSql);
$releases[0]->_totalrows = $countResult[0]->count ?? 0;
}
if ($shouldCache && $cacheKey !== null) {
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
}
return $releases;
}
/**
* Search TV Shows via APIv2.
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCategories
* @param array<string, mixed> $siteIdArr
* @return Collection|mixed
*/
public function apiTvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
{
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
$hasStrictTvSelector = $this->hasTvLookupIdentifiers($siteIdArr);
// OPTIMIZATION: Try to find releases using search index external IDs first
$externalIds = [];
foreach ($siteIdArr as $column => $Id) {
$hasValue = $column === 'imdb'
? ($Id !== null && $Id !== '' && imdb_id_is_valid((string) $Id))
: ($Id > 0);
if ($hasValue && $column !== 'id') {
$fieldName = match ($column) {
'tvdb' => 'tvdb',
'trakt' => 'traktid',
'tvmaze' => 'tvmaze',
'tvrage' => 'tvrage',
'imdb' => 'imdbid',
'tmdb' => 'tmdbid',
default => null,
};
if ($fieldName) {
$externalIds[$fieldName] = $column === 'imdb' ? (string) $Id : (int) $Id;
}
}
}
// Try to get releases directly from search index using external IDs
$indexSearchResult = [];
if (! empty($externalIds)) {
$indexSearchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);
if (config('app.debug') && ! empty($indexSearchResult)) {
Log::debug('apiTvSearch: Found releases via search index by external IDs', [
'externalIds' => $externalIds,
'count' => count($indexSearchResult),
]);
}
}
// Resolve show / episode filters from videos + tv_episodes when the index missed, or
// when the index hit but the client narrowed by season/episode/airdate (index has no S/E).
$siteSQL = $this->buildApiTvSiteSqlArray($siteIdArr);
$showSql = '';
if (\count($siteSQL) > 0) {
$shouldLookupShow = empty($indexSearchResult)
|| $this->tvApiRequestTargetsEpisode($series, $episode, $airDate);
if ($shouldLookupShow) {
$strictEpisodeResolution = ! empty($indexSearchResult)
&& $this->tvApiRequestTargetsEpisode($series, $episode, $airDate);
$fragment = $this->lookupApiTvShowSqlFragment(
$siteSQL,
$series,
$episode,
$airDate,
$strictEpisodeResolution
);
if ($fragment === null) {
$this->logStrictExternalLookupMiss('apiTvSearch show lookup', $siteIdArr, [
'series' => $series,
'episode' => $episode,
'airDate' => $airDate,
]);
return empty($indexSearchResult) ? [] : collect();
}
$showSql = $fragment;
}
}
if (! $hasStrictTvSelector && ! empty($name) && $showSql === '' && empty($indexSearchResult)) {
if (! empty($series) && (int) $series < 1900) {
$name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
if (! empty($episode) && ! str_contains($episode, '/')) {
$name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
}
if (empty($episode)) {
$name .= '*';
}
} elseif (! empty($airDate)) {
$name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate));
}
}
$searchResult = $indexSearchResult; // Use index search result if we have it
if (! $hasStrictTvSelector && empty($searchResult) && ! empty($name)) {
$searchResult = Search::searchReleases(['searchname' => $name], $searchLimit);
// Fall back to MySQL if search engine failed (only if enabled)
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
}
if (count($searchResult) === 0) {
return collect();
}
}
$skipSqlReleaseFilters = false;
if ($searchResult !== [] && Search::isAvailable()) {
$searchResult = $this->intersectReleaseIdsWithSearchFilters(
$searchResult,
$cat,
'tv',
$maxAge,
$minSize,
$excludedCategories
);
$skipSqlReleaseFilters = true;
if ($searchResult === []) {
return collect();
}
}
$catSearchSql = $skipSqlReleaseFilters ? '' : Category::getCategorySearch($cat, 'tv');
$whereSql = sprintf(
'WHERE r.passwordstatus %s %s %s %s %s %s %s',
$this->showPasswords(),
$showSql,
(! empty($searchResult) ? 'AND r.id IN ('.implode(',', array_map('intval', $searchResult)).')' : ''),
$catSearchSql,
($skipSqlReleaseFilters || $maxAge <= 0 ? '' : sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge)),
($skipSqlReleaseFilters || $minSize <= 0 ? '' : sprintf('AND r.size >= %d', $minSize)),
($skipSqlReleaseFilters || empty($excludedCategories) ? '' : sprintf('AND r.categories_id NOT IN(%s)', implode(',', array_map('intval', $excludedCategories))))
);
$baseSql = sprintf(
"SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name
FROM releases r
LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0
LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
LEFT JOIN categories c ON c.id = r.categories_id
LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
%s",
$whereSql
);
$sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset);
$cacheKey = md5($this->getCacheVersion().'apiTvSearch:v2:'.$sql);
$releases = Cache::get($cacheKey);
if ($releases !== null) {
return $releases;
}
$releases = Release::fromQuery($sql);
if ($hasStrictTvSelector && $releases->isEmpty()) {
$this->logStrictExternalLookupMiss('apiTvSearch release lookup', $siteIdArr, [
'series' => $series,
'episode' => $episode,
'airDate' => $airDate,
]);
}
if ($releases->isNotEmpty()) {
$releases[0]->_totalrows = $this->getPagerCount(
preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql)
);
}
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
/**
* Search anime releases.
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCategories
* @return Collection|mixed
*/
public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1, string $orderBy = 'posted_desc'): mixed
{
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
if ($anilistId > 0) {
$resolved = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid');
if ($resolved !== null) {
$aniDbID = (int) $resolved;
} elseif ($name === '' && (int) $aniDbID <= -1) {
// AniList id was the only selector but nothing in DB maps to it — do not fall through unfiltered.
return collect();
}
}
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
$searchResult = [];
$anidbIdFilter = '';
if ($name !== '') {
if (Search::isAvailable()) {
$anidbIds = Search::searchAnimeTitle($name, $searchLimit);
if ($anidbIds === []) {
return collect();
}
$anidbIdFilter = 'AND r.anidbid IN ('.implode(',', array_map('intval', $anidbIds)).')';
} else {
$fuzzyResult = Search::searchReleasesWithFuzzy($name, $searchLimit);
$searchResult = $fuzzyResult['ids'] ?? [];
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
}
if (count($searchResult) === 0) {
return collect();
}
}
}
$whereSql = sprintf(
'WHERE r.passwordstatus %s
%s %s %s %s %s %s',
$this->showPasswords(),
($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''),
$anidbIdFilter,
(! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '',
Category::getCategorySearch($cat, 'anime'),
($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '')
);
$baseSql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.haspreview, r.jpgstatus, cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name,
rn.releases_id AS nfoid
FROM releases r
LEFT JOIN categories c ON c.id = r.categories_id
LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
%s",
$whereSql
);
$sql = sprintf(
'%s
ORDER BY %s %s
LIMIT %d OFFSET %d',
$baseSql,
$orderField,
$orderDir,
$limit,
$offset
);
$cacheKey = md5($this->getCacheVersion().$sql);
$releases = Cache::get($cacheKey);
if ($releases !== null) {
return $releases;
}
$releases = Release::fromQuery($sql);
if ($releases->isNotEmpty()) {
$releases[0]->_totalrows = $this->getPagerCount($baseSql);
}
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
Cache::put($cacheKey, $releases, $expiresAt);
return $releases;
}
/**
* Movies search through API and site.
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCategories
* @return Collection|mixed
*/
public function moviesSearch(string $imDbId = '', int $tmDbId = -1, int $traktId = -1, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
{
[$orderField, $orderDir] = $this->getBrowseOrder($orderBy);
$searchLimit = $this->determineSearchCandidateLimit($offset, $limit);
$searchResult = [];
// OPTIMIZATION: If we have external IDs, use the search index to find releases directly
// This avoids expensive database JOINs by using indexed external ID fields in releases_rt
$externalIds = [];
if ($imDbId !== '' && imdb_id_is_valid($imDbId)) {
$externalIds['imdbid'] = $imDbId;
}
if ($tmDbId !== -1 && $tmDbId > 0) {
$externalIds['tmdbid'] = $tmDbId;
}
if ($traktId !== -1 && $traktId > 0) {
$externalIds['traktid'] = $traktId;
}
$hasExternalIds = $externalIds !== [];
// Use search index for external ID lookups (much faster than database JOINs)
if ($hasExternalIds) {
$searchResult = Search::searchReleasesByExternalId($externalIds, $searchLimit);
if (config('app.debug') && ! empty($searchResult)) {
Log::debug('moviesSearch: Found releases via search index by external IDs', [
'externalIds' => $externalIds,
'count' => count($searchResult),
]);
}
}
// Only perform name searches when the request does not already target a specific external ID.
if (! $hasExternalIds && ! empty($name)) {
$searchResult = Search::searchReleases(['searchname' => $name], $searchLimit);
// Fall back to MySQL if search engine returned no results (only if enabled)
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit);
}
// Only return empty if we were specifically searching by name but found nothing
if (empty($searchResult)) {
return collect();
}
}
if (! empty($searchResult) && Search::isAvailable()) {
$searchResult = $this->intersectReleaseIdsWithSearchFilters(
$searchResult,
$cat,
'movies',
$maxAge,
$minSize,
$excludedCategories
);
if ($searchResult === []) {
return collect();
}
}
// Build the base conditions for movie search
// Note: we don't have MOVIE_ROOT constant that marks a parent category,
// so we'll rely on the category search logic instead
$conditions = [
sprintf('r.passwordstatus %s', $this->showPasswords()),
];
if (! empty($searchResult)) {
$conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
}
// When we have external IDs but no index results, fall back to database query
// This handles the case where the index might be empty/out of sync
$needsMovieJoin = false;
if (empty($searchResult) && ! empty($externalIds)) {
$needsMovieJoin = true;
if ($imDbId !== '' && imdb_id_is_valid($imDbId)) {
$conditions[] = sprintf('r.imdbid = %s', escapeString($imDbId));
}
if ($tmDbId !== -1 && $tmDbId > 0) {
$conditions[] = sprintf('m.tmdbid = %d', $tmDbId);
}
if ($traktId !== -1 && $traktId > 0) {
$conditions[] = sprintf('m.traktid = %d', $traktId);
}
}
$applySqlMovieFilters = $searchResult === [] || ! Search::isAvailable();
if ($applySqlMovieFilters) {
if (! empty($excludedCategories)) {
$conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
}
$catQuery = Category::getCategorySearch($cat, 'movies');
$catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
if (! empty($catQuery) && $catQuery !== '1=1') {
$conditions[] = $catQuery;
}
if ($maxAge > 0) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
}
if ($minSize > 0) {
$conditions[] = sprintf('r.size >= %d', $minSize);
}
}
$whereSql = 'WHERE '.implode(' AND ', $conditions);
// Only join movieinfo if we need to filter by tmdbid/traktid (database fallback)
// When using search index, we already have the release IDs and don't need the join
$joinSql = $needsMovieJoin ? 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid' : 'LEFT JOIN movieinfo m ON m.id = r.movieinfo_id';
// Select only fields required by XML/API transformers
$baseSql = sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id,
r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
r.adddate,
%s
cp.title AS parent_category, c.title AS sub_category,
CONCAT(cp.title, ' > ', c.title) AS category_name,
g.name AS group_name
FROM releases r
INNER JOIN categories c ON c.id = r.categories_id
INNER JOIN root_categories cp ON cp.id = c.root_categories_id
%s
LEFT JOIN usenet_groups g ON g.id = r.groups_id
%s",
'm.imdbid, m.tmdbid, m.traktid,',
$joinSql,
$whereSql
);
$sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset);
$cacheKey = md5('moviesSearch:v2:'.$sql.serialize(func_get_args()));
if (($releases = Cache::get($cacheKey)) !== null) {
return $releases;
}
$releases = Release::fromQuery($sql);
if ($hasExternalIds && $releases->isEmpty()) {
$this->logStrictExternalLookupMiss('moviesSearch release lookup', $externalIds, [
'name' => $name,
]);
}
if ($releases->isNotEmpty()) {
// Optimize: Execute count query using same WHERE clause
$countSql = sprintf(
'SELECT COUNT(*) as count FROM releases r %s %s',
$needsMovieJoin ? $joinSql : '',
$whereSql
);
$countResult = DB::selectOne($countSql);
$releases[0]->_totalrows = $countResult->count ?? 0;
}
Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));
return $releases;
}
/**
* @param array<int, int> $excludedCats
* @return array<string, mixed>
*/
public function searchSimilar(mixed $currentID, mixed $name, array $excludedCats = []): bool|array
{
// Get the category for the parent of this release.
$ret = false;
$currRow = Release::getCatByRelId($currentID);
if ($currRow !== null) {
$catRow = Category::find($currRow['categories_id']);
$parentCat = $catRow !== null ? $catRow['root_categories_id'] : null;
if ($parentCat === null) {
return $ret;
}
$results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, (int) config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]);
if (! $results) {
return $ret;
}
$ret = [];
foreach ($results as $res) {
if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) {
$ret[] = $res;
}
}
}
return $ret;
}
/**
* Perform index search using Elasticsearch or Manticore, with MySQL fallback
*
* @param array<string, mixed> $searchArr
* @return array<string, mixed>
*/
private function performIndexSearch(array $searchArr, int $limit): array
{
// Filter out -1 values and empty strings
$searchFields = Arr::where($searchArr, static function ($value) {
return $value !== -1 && $value !== '' && $value !== null;
});
if (empty($searchFields)) {
if (config('app.debug')) {
Log::debug('performIndexSearch: searchFields is empty after filtering', [
'original' => $searchArr,
]);
}
return [];
}
if (config('app.debug')) {
Log::debug('performIndexSearch: starting search', [
'search_driver' => config('search.default'),
'searchFields' => $searchFields,
'limit' => $limit,
]);
}
// Use the unified Search facade with fuzzy fallback
// This will try exact search first, then fuzzy if no results
$searchResult = Search::searchReleasesWithFuzzy($searchFields, $limit);
$result = $searchResult['ids'] ?? [];
if (config('app.debug')) {
Log::debug('performIndexSearch: Search result', [
'count' => count($result),
'fuzzy_used' => $searchResult['fuzzy'] ?? false,
]);
}
// If search returned results, use them
if (! empty($result)) {
return $result;
}
// Fallback to MySQL LIKE search when search engine is unavailable (only if enabled)
if (config('nntmux.mysql_search_fallback', false) === true) {
if (config('app.debug')) {
Log::debug('performIndexSearch: Falling back to MySQL search');
}
return $this->performMySQLSearch($searchFields, $limit);
}
return [];
}
/**
* SQL predicates on tv_episodes (alias tve) for season / episode / airdate, matching
* newznab-style tvsearch parameters. Empty array means no narrowing.
*
* @return list<string>
*/
private function buildEpisodeJoinPredicates(string $series, string $episode, string $airDate): array
{
$predicates = [];
if (! empty($series) && (int) $series < 1900) {
$seriesNum = (int) preg_replace('/^s0*/i', '', $series);
$predicates[] = sprintf('tve.series = %d', $seriesNum);
if (! empty($episode) && ! str_contains($episode, '/')) {
$episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
$predicates[] = sprintf('tve.episode = %d', $episodeNum);
}
} elseif (! empty($airDate)) {
$predicates[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
}
return $predicates;
}
/**
* @param array<string, mixed> $siteIdArr
* @return list<string>
*/
private function buildApiTvSiteSqlArray(array $siteIdArr): array
{
$siteSQL = [];
foreach ($siteIdArr as $column => $Id) {
if ($Id > 0) {
$siteSQL[] = sprintf('v.%s = %d', $column, $Id);
}
}
return $siteSQL;
}
private function tvApiRequestTargetsEpisode(string $series, string $episode, string $airDate): bool
{
if ($airDate !== '') {
return true;
}
if ($series !== '' && (int) $series < 1900) {
return true;
}
if ($episode !== '' && ! str_contains($episode, '/')) {
return true;
}
return false;
}
/**
* Build the extra WHERE fragment for API TV search (r.tv_episodes_id / r.videos_id / tve.series).
*
* @param list<string> $siteSQL
*/
private function lookupApiTvShowSqlFragment(
array $siteSQL,
string $series,
string $episode,
string $airDate,
bool $strictEpisodeResolution
): ?string {
$showQry = sprintf(
"\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1",
implode(' OR ', $siteSQL),
($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''),
($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''),
($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '')
);
$show = Release::fromQuery($showQry);
if ($show->isEmpty()) {
return null;
}
$showSql = '';
if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') {
$showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
} elseif (! empty($episode) && $show[0]->episodes !== '') {
$showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes);
} elseif (! empty($series) && empty($episode)) {
$showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
}
if ((int) ($show[0]->video ?? 0) > 0) {
$showSql .= ' AND r.videos_id = '.$show[0]->video;
}
if ($strictEpisodeResolution
&& $this->tvApiRequestTargetsEpisode($series, $episode, $airDate)
&& ($show[0]->episodes === '' || $show[0]->episodes === null)) {
return null;
}
return $showSql;
}
/**
* Search-backed pages still apply SQL filters and ordering after the index lookup,
* so fetch a buffered candidate set without handing MySQL thousands of IDs.
*/
private function determineSearchCandidateLimit(int $offset, int $limit): int
{
$pageSize = max(1, $limit);
$requestedRows = max($pageSize, $offset + $pageSize);
$bufferedRows = max(
$requestedRows,
$offset + ($pageSize * self::SEARCH_INDEX_BUFFER_PAGES),
self::SEARCH_INDEX_MIN_CANDIDATES
);
return min($bufferedRows, self::SEARCH_INDEX_MAX_CANDIDATES);
}
/**
* Narrow release IDs using the search index (category, age, size, password).
*
* @param array<int|string, mixed> $cat
* @param array<int|string, mixed> $excludedCategories
* @return list<int>
*/
private function intersectReleaseIdsWithSearchFilters(
array $releaseIds,
array $cat,
?string $categorySearchType,
int $maxAge,
int $minSize,
array $excludedCategories
): array {
if ($releaseIds === [] || ! Search::isAvailable()) {
return array_values(array_unique(array_map(static fn ($id): int => (int) $id, $releaseIds)));
}
$unique = array_values(array_unique(array_filter(
array_map(static fn ($id): int => (int) $id, $releaseIds),
static fn (int $id): bool => $id > 0
)));
if ($unique === []) {
return [];
}
$categoryIdsRaw = Category::getCategorySearch($cat, $categorySearchType, true);
$categoryIds = null;
if (is_array($categoryIdsRaw)) {
$categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw);
} elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
$categoryIds = [(int) $categoryIdsRaw];
}
$maxMatches = (int) config('search.drivers.manticore.max_matches', 10000);
$intersectLimit = min(max(count($unique), 1), max(1000, $maxMatches));
$filtered = Search::searchReleasesFiltered([
'phrases' => null,
'release_ids' => $unique,
'category_ids' => $categoryIds,
'excluded_category_ids' => array_map(static fn ($id): int => (int) $id, $excludedCategories),
'min_size' => $minSize,
'max_age_days' => $maxAge,
'password_allow_rar' => $this->passwordAllowRar(),
'sort_field' => 'postdate_ts',
'sort_dir' => 'desc',
'try_fuzzy' => false,
], $intersectLimit, 0);
return $filtered['ids'];
}
/**
* Fallback MySQL search when full-text search engines are unavailable
*
* @param array<string, mixed> $searchFields
* @return array<string, mixed>
*/
private function performMySQLSearch(array $searchFields, int $limit): array
{
try {
$query = Release::query()->select('id');
foreach ($searchFields as $field => $value) {
if (! empty($value)) {
// Split search terms and search for each
$terms = preg_split('/\s+/', trim($value));
foreach ($terms as $term) {
$term = trim($term);
if (strlen($term) >= 2) {
$query->where($field, 'LIKE', '%'.$term.'%');
}
}
if ($field === 'searchname' && count($terms) > 1) {
$normalizedPhrase = $this->normalizeMysqlFallbackPhrase($value);
if ($normalizedPhrase !== '') {
$query->whereRaw(
"LOWER(REPLACE(REPLACE(REPLACE($field, '.', ' '), '-', ' '), '_', ' ')) LIKE ?",
[$normalizedPhrase.'%']
);
}
}
}
}
$results = $query->limit($limit)->pluck('id')->toArray();
if (config('app.debug')) {
Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
}
return $results;
} catch (\Throwable $e) {
Log::error('performMySQLSearch: MySQL fallback failed', [
'error' => $e->getMessage(),
]);
return [];
}
}
private function normalizeMysqlFallbackPhrase(string $value): string
{
$normalized = strtolower(trim($value));
$normalized = str_replace(['.', '-', '_'], ' ', $normalized);
$normalized = preg_replace('/\s+/', ' ', $normalized);
return trim((string) $normalized);
}
/**
* @param array<string, mixed> $siteIdArr
*/
private function hasTvLookupIdentifiers(array $siteIdArr): bool
{
foreach ($siteIdArr as $column => $id) {
if ($column === 'imdb' && imdb_id_is_valid((string) $id)) {
return true;
}
if ((int) $id > 0) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $identifiers
* @param array<string, mixed> $context
*/
private function logStrictExternalLookupMiss(string $operation, array $identifiers, array $context = []): void
{
if (! config('app.debug')) {
return;
}
Log::debug($operation.' returned no releases', array_merge([
'identifiers' => $identifiers,
], $context));
}
/**
* Build WHERE clause for search query
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
* @param array<int, int> $excludedCats
* @param array<string, mixed> $searchResult
*/
private function buildSearchWhereClause(
array $searchResult,
mixed $groupName,
mixed $sizeFrom,
mixed $sizeTo,
mixed $daysNew,
mixed $daysOld,
int $maxAge,
array $excludedCats,
array $cat,
int $minSize
): string {
$conditions = [
sprintf('r.passwordstatus %s', $this->showPasswords()),
sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
];
// Add optional conditions
if ($maxAge > 0) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
}
if ((int) $groupName !== -1) {
$groupId = UsenetGroup::getIDByName($groupName);
if ($groupId) {
$conditions[] = sprintf('r.groups_id = %d', $groupId);
}
}
// Size conditions
$sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
if (! empty($sizeConditions)) {
$conditions = array_merge($conditions, $sizeConditions);
}
if ($minSize > 0) {
$conditions[] = sprintf('r.size >= %d', $minSize);
}
// Category conditions - only add if not empty
$catQuery = $this->buildCategoryCondition($cat);
if (! empty($catQuery) && $catQuery !== '1=1') {
$conditions[] = $catQuery;
}
// Date conditions
if ((int) $daysNew !== -1) {
$conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
}
if ((int) $daysOld !== -1) {
$conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
}
// Excluded categories
if (! empty($excludedCats)) {
$excludedCatsClean = array_map('intval', $excludedCats);
$conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
}
return 'WHERE '.implode(' AND ', $conditions);
}
/**
* Build size conditions for WHERE clause
*
* @return array<string, mixed>
*/
private function buildSizeConditions(mixed $sizeFrom, mixed $sizeTo): array
{
$conditions = [];
[$sizeMin, $sizeMax] = $this->resolveSizeRangeBounds($sizeFrom, $sizeTo);
if ($sizeMin > 0) {
$conditions[] = sprintf('r.size > %d', $sizeMin);
}
if ($sizeMax > 0) {
$conditions[] = sprintf('r.size < %d', $sizeMax);
}
return $conditions;
}
/**
* Resolve UI size bucket inputs into byte bounds used by SQL/index filters.
*
* @return array{0:int,1:int}
*/
private function resolveSizeRangeBounds(mixed $sizeFrom, mixed $sizeTo): array
{
$sizeRange = [
1 => 1,
2 => 2.5,
3 => 5,
4 => 10,
5 => 20,
6 => 30,
7 => 40,
8 => 80,
9 => 160,
10 => 320,
11 => 640,
];
$sizeMin = 0;
$sizeMax = 0;
if (array_key_exists($sizeFrom, $sizeRange)) {
$sizeMin = 104857600 * (int) $sizeRange[$sizeFrom];
}
if (array_key_exists($sizeTo, $sizeRange)) {
$sizeMax = 104857600 * (int) $sizeRange[$sizeTo];
}
return [$sizeMin, $sizeMax];
}
/**
* Resolve day-based age inputs to unix timestamps used by indexed filtering.
*
* @return array{0:int,1:int}
*/
private function resolvePostdateBounds(mixed $daysNew, mixed $daysOld): array
{
$minDate = 0;
$maxDate = 0;
if ((int) $daysOld !== -1 && (int) $daysOld >= 0) {
$minDate = time() - ((int) $daysOld * 86400);
}
if ((int) $daysNew !== -1 && (int) $daysNew >= 0) {
$maxDate = time() - ((int) $daysNew * 86400);
}
return [$minDate, $maxDate];
}
/**
* Resolve leaf category IDs for search/browse filters (expands root categories).
*
* @param array<int|string, mixed> $cat
* @param array<int|string, mixed> $excludedCats
* @return array<int>|null null = no category filter; [] = filter matches nothing
*/
private function resolveSearchCategoryIds(array $cat, array $excludedCats): ?array
{
$categoryIdsRaw = Category::getCategorySearch($cat, null, true);
$categoryIds = null;
if (is_array($categoryIdsRaw)) {
$categoryIds = array_values(array_filter(
array_map(static fn ($id): int => (int) $id, $categoryIdsRaw),
static fn (int $id): bool => $id > 0
));
} elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) {
$categoryIds = [(int) $categoryIdsRaw];
}
if (! is_array($categoryIds) || $categoryIds === []) {
return $categoryIds;
}
if ($excludedCats === []) {
return $categoryIds;
}
$excluded = array_map(static fn ($id): int => (int) $id, $excludedCats);
return array_values(array_diff($categoryIds, $excluded));
}
/**
* Build category condition for web search (basic and advanced).
*
* @param array<int|string, mixed> $cat Category IDs (list or associative)
*/
private function buildCategoryCondition(array $cat): string
{
$catSearch = Category::getCategorySearch($cat);
// Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
$catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catSearch));
// Don't return '1=1' as it's not needed
return ($catSearch === '1=1') ? '' : $catSearch;
}
/**
* Build base SQL for web search query.
* Selects only columns needed by search/index.blade.php and browse/index.blade.php views.
*/
private function buildSearchBaseSql(string $whereSql): string
{
return sprintf(
"SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size,
r.totalpart, r.fromname, r.grabs, r.comments, r.adddate,
r.videos_id, r.haspreview, r.jpgstatus, r.nfostatus,
CONCAT(cp.title, ' > ', c.title) AS category_name,
MAX(df.failed) AS failed_count,
COUNT(DISTINCT rr.id) AS total_report_count,
COUNT(DISTINCT CASE WHEN rr.status IN ('pending', 'reviewed', 'resolved') THEN rr.id ELSE NULL END) AS report_count,
GROUP_CONCAT(DISTINCT CASE WHEN rr.status IN ('pending', 'reviewed', 'resolved') THEN rr.reason ELSE NULL END SEPARATOR ', ') AS report_reasons,
GROUP_CONCAT(DISTINCT rr.reason SEPARATOR ', ') AS all_report_reasons,
SUBSTRING_INDEX(GROUP_CONCAT(rr.reason ORDER BY rr.created_at DESC SEPARATOR ','), ',', 1) AS latest_report_reason,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(rr.description, ''), 'No additional report details were provided.') ORDER BY rr.created_at DESC SEPARATOR ' || '), ' || ', 1) AS latest_report_description,
COUNT(DISTINCT CASE WHEN rr.response IS NOT NULL AND rr.response != '' AND rr.response_is_public = 1 THEN rr.id ELSE NULL END) AS report_response_count,
MAX(CASE WHEN rr.response IS NOT NULL AND rr.response != '' AND rr.response_is_public = 1 THEN rr.responded_at ELSE NULL END) AS latest_report_response_at,
g.name AS group_name,
rn.releases_id AS nfoid,
re.releases_id AS reid,
m.imdbid
FROM releases r
LEFT OUTER JOIN video_data re ON re.releases_id = r.id
LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id
LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
LEFT JOIN usenet_groups g ON g.id = r.groups_id
LEFT JOIN categories c ON c.id = r.categories_id
LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
LEFT OUTER JOIN release_reports rr ON rr.releases_id = r.id
%s
GROUP BY r.id",
$whereSql
);
}
/**
* Build the lightweight paged ID query used by compatibility tests and targeted callers.
*
* @param array{0: string, 1: string} $orderBy
*/
protected function buildSearchPageIdsSql(string $whereSql, array $orderBy, int $limit, int $offset): string
{
return sprintf(
'SELECT r.id FROM releases r %s ORDER BY r.%s %s LIMIT %d OFFSET %d',
$whereSql,
$orderBy[0],
$orderBy[1],
$limit,
$offset,
);
}
/**
* Get a lightweight releases count directly from the releases table for compatibility callers.
*/
protected function getReleasesCountForWhere(string $whereSql): int
{
return $this->getPagerCount(sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql));
}
/**
* Get the passworded releases clause.
*/
public function showPasswords(): string
{
$show = (int) Settings::settingValue('showpasswordedrelease');
$setting = $show;
return match ($setting) {
1 => '<= '.self::PASSWD_RAR,
default => '<= '.self::PASSWD_NONE,
};
}
/**
* When true, search/browse includes RAR-passworded releases (passwordstatus <= 1).
*/
private function passwordAllowRar(): bool
{
return (int) Settings::settingValue('showpasswordedrelease') === 1;
}
/**
* Use to order releases on site.
*
* @param array<string, mixed> $orderBy
* @return array<string, mixed>
*/
public function getBrowseOrder(array|string $orderBy): array
{
$orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy));
$orderField = match ($orderArr[0]) {
'cat' => 'categories_id',
'name' => 'searchname',
'size' => 'size',
'files' => 'totalpart',
'stats' => 'grabs',
'added' => 'adddate',
default => 'postdate',
};
return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; // @phpstan-ignore return.type
}
private function browseOrderToIndexSortField(string $orderField): string
{
return match ($orderField) {
'postdate' => 'postdate_ts',
'adddate' => 'adddate_ts',
'categories_id' => 'categories_id',
'searchname' => 'searchname',
'size' => 'size',
'totalpart' => 'totalpart',
'grabs' => 'grabs',
default => 'postdate_ts',
};
}
private function getCacheVersion(): int
{
return (int) Cache::get(self::CACHE_VERSION_KEY, 1);
}
/**
* Get the count of releases for pager.
*
* @param string $query The query to get the count from.
*/
protected function getPagerCount(string $query): int
{
$maxResults = (int) config('nntmux.max_pager_results');
$cacheExpiry = config('nntmux.cache_expiry_short');
// Generate cache key from original query
$cacheKey = 'pager_count_'.md5($query);
// Check cache first
$count = Cache::get($cacheKey);
if ($count !== null) {
return (int) $count;
}
// Check if this is already a COUNT query
if (preg_match('/SELECT\s+COUNT\s*\(/is', $query)) {
// It's already a COUNT query, just execute it
try {
$result = DB::select($query);
if (isset($result[0])) {
// Handle different possible column names
$count = $result[0]->count ?? $result[0]->total ?? 0;
// Check for COUNT(*) result without alias
if ($count === 0) {
foreach ($result[0] as $value) {
$count = (int) $value;
break;
}
}
} else {
$count = 0;
}
// Cap the count at max results to prevent extremely large pagers
if ($maxResults > 0 && $count > $maxResults) {
$count = $maxResults;
}
// Cache the result
Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
return $count;
} catch (\Exception $e) {
return 0;
}
}
// For regular SELECT queries, optimize for counting
$countQuery = $query;
// Remove ORDER BY clause (not needed for COUNT)
$countQuery = preg_replace('/ORDER\s+BY\s+[^)]+$/is', '', $countQuery);
$hadGroupBy = preg_match('/GROUP\s+BY/is', $countQuery);
// Remove GROUP BY if it's only grouping by r.id
$countQuery = preg_replace('/GROUP\s+BY\s+r\.id\s*$/is', '', $countQuery);
// Check if query has DISTINCT in SELECT
$hasDistinct = preg_match('/SELECT\s+DISTINCT/is', $countQuery);
// Replace SELECT clause with COUNT
if ($hasDistinct || $hadGroupBy || preg_match('/GROUP\s+BY/is', $countQuery)) {
// For queries with DISTINCT or GROUP BY, count distinct r.id
$countQuery = preg_replace(
'/SELECT\s+.+?\s+FROM/is',
'SELECT COUNT(DISTINCT r.id) as count FROM',
$countQuery
);
} else {
// For simple queries, use COUNT(*)
$countQuery = preg_replace(
'/SELECT\s+.+?\s+FROM/is',
'SELECT COUNT(*) as count FROM',
$countQuery
);
}
// Remove LIMIT/OFFSET from the count query
$countQuery = preg_replace('/LIMIT\s+\d+(\s+OFFSET\s+\d+)?$/is', '', $countQuery);
try {
// Execute the count query directly
$result = DB::select($countQuery);
$count = isset($result[0]) ? (int) $result[0]->count : 0;
// Cap the count at max results to prevent extremely large pagers
if ($maxResults > 0 && $count > $maxResults) {
$count = $maxResults;
}
// Cache the result
Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
return $count;
} catch (\Exception $e) {
// If optimization fails, try a simpler approach
try {
// Extract the core table and WHERE conditions
if (preg_match('/FROM\s+releases\s+r\s+(.+?)(?:ORDER\s+BY|LIMIT|$)/is', $query, $matches)) {
$conditions = $matches[1];
// Remove JOINs but keep WHERE
$conditions = preg_replace('/(?:LEFT\s+|INNER\s+)?(?:OUTER\s+)?JOIN\s+.+?(?=WHERE|LEFT|INNER|JOIN|$)/is', '', $conditions);
$fallbackQuery = sprintf('SELECT COUNT(*) as count FROM releases r %s', trim($conditions));
$result = DB::select($fallbackQuery);
$count = isset($result[0]) ? (int) $result[0]->count : 0;
// Cap the count at max results
if ($maxResults > 0 && $count > $maxResults) {
$count = $maxResults;
}
Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
return $count;
}
} catch (\Exception $fallbackException) {
// Log the error for debugging
Log::error('getPagerCount failed', [
'query' => $query,
'error' => $fallbackException->getMessage(),
]);
}
return 0;
}
}
}