BioTorrents.de’s version of Gazelle
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

torrentsearch.class.php 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. <?php
  2. class TorrentSearch
  3. {
  4. const TAGS_ANY = 0;
  5. const TAGS_ALL = 1;
  6. const SPH_BOOL_AND = ' ';
  7. const SPH_BOOL_OR = ' | ';
  8. // Map of sort mode => attribute name for ungrouped torrent page
  9. public static $SortOrders = [
  10. 'year' => 'year',
  11. 'time' => 'id',
  12. 'size' => 'size',
  13. 'seeders' => 'seeders',
  14. 'leechers' => 'leechers',
  15. 'snatched' => 'snatched',
  16. 'cataloguenumber' => 'cataloguenumber',
  17. 'random' => 1
  18. ];
  19. // Map of sort mode => attribute name for grouped torrent page
  20. private static $SortOrdersGrouped = [
  21. 'year' => 'year',
  22. 'time' => 'id',
  23. 'size' => 'maxsize',
  24. 'seeders' => 'sumseeders',
  25. 'leechers' => 'sumleechers',
  26. 'snatched' => 'sumsnatched',
  27. 'cataloguenumber' => 'cataloguenumber',
  28. 'random' => 1
  29. ];
  30. // Map of sort mode => aggregate expression required for some grouped sort orders
  31. private static $AggregateExp = [
  32. 'size' => 'MAX(size) AS maxsize',
  33. 'seeders' => 'SUM(seeders) AS sumseeders',
  34. 'leechers' => 'SUM(leechers) AS sumleechers',
  35. 'snatched' => 'SUM(snatched) AS sumsnatched'
  36. ];
  37. // Map of attribute name => global variable name with list of values that can be used for filtering
  38. private static $Attributes = [
  39. 'filter_cat' => false,
  40. 'releasetype' => 'ReleaseTypes',
  41. 'freetorrent' => false,
  42. 'censored' => false,
  43. 'size_unit' => false,
  44. 'year' => false
  45. ];
  46. // List of fields that can be used for fulltext searches
  47. private static $Fields = [
  48. 'artistname' => 1, # Author
  49. 'audioformat' => 1, # Version
  50. 'cataloguenumber' => 1, # Accession Number
  51. 'numbers' => 1, # Combined ↑
  52. 'codec' => 1, # License
  53. 'container' => 1, # Format
  54. 'archive' => 0, # todo
  55. 'description' => 1, # Not group desc
  56. 'dlsiteid' => 1,
  57. 'filelist' => 1,
  58. 'groupname' => 1, # Title
  59. 'groupnamerj' => 1, # Organism
  60. 'groupnamejp' => 1, # Strain
  61. 'advgroupname' => 1,
  62. 'language' => 1,
  63. 'media' => 1, # Platform
  64. 'resolution' => 1, # Assembly Level
  65. 'searchstr' => 1,
  66. 'series' => 1, # Location
  67. 'studio' => 1, # Department/Lab
  68. 'location' => 1, # Combined ↑
  69. 'subber' => 1,
  70. 'subbing' => 1,
  71. 'taglist' => 1
  72. ];
  73. // List of torrent-specific fields that can be used for filtering
  74. private static $TorrentFields = [
  75. 'description' => 1,
  76. 'encoding' => 1,
  77. 'censored' => 1,
  78. 'language' => 1,
  79. 'filelist' => 1,
  80. 'format' => 1,
  81. 'media' => 1
  82. ];
  83. // Some form field names don't match the ones in the index
  84. private static $FormsToFields = [
  85. # todo: Keep testing the granularity of filter combos
  86. 'searchstr' => '*',
  87. 'advgroupname' => '*', # todo: Fix this ;)
  88. 'numbers' => '(cataloguenumber,audioformat)',
  89. 'location' => '(studio,series)'
  90. #'searchstr' => '(groupname,groupnamerj,groupnamejp,artistname,studio,series,dlsiteid,cataloguenumber,yearfulltext)',
  91. #'advgroupname' => '(groupname,groupnamerj,groupnamejp)'
  92. ];
  93. // Specify the operator type to use for fields. Empty key sets the default
  94. private static $FieldOperators = [
  95. '' => self::SPH_BOOL_AND,
  96. 'encoding' => self::SPH_BOOL_OR,
  97. 'format' => self::SPH_BOOL_OR,
  98. 'media' => self::SPH_BOOL_OR
  99. ];
  100. // Specify the separator character to use for fields. Empty key sets the default
  101. private static $FieldSeparators = [
  102. '' => ' ',
  103. 'encoding' => '|',
  104. 'format' => '|',
  105. 'media' => '|',
  106. 'taglist' => ','
  107. ];
  108. // Primary SphinxqlQuery object used to get group IDs or torrent IDs for ungrouped searches
  109. private $SphQL;
  110. // Second SphinxqlQuery object used to get torrent IDs if torrent-specific fulltext filters are used
  111. private $SphQLTor;
  112. // Ordered result array or false if query resulted in an error
  113. private $SphResults;
  114. // Requested page
  115. private $Page;
  116. // Number of results per page
  117. private $PageSize;
  118. // Number of results
  119. private $NumResults = 0;
  120. // Array with info from all matching torrent groups
  121. private $Groups = [];
  122. // Whether any filters were used
  123. private $Filtered = false;
  124. // Whether the random sort order is selected
  125. private $Random = false;
  126. /*
  127. * Storage for fulltext search terms
  128. * ['Field name' => [
  129. * 'include' => [],
  130. * 'exclude' => [],
  131. * 'operator' => self::SPH_BOOL_AND | self::SPH_BOOL_OR
  132. * ]], ...
  133. */
  134. private $Terms = [];
  135. // Unprocessed search terms for retrieval
  136. private $RawTerms = [];
  137. // Storage for used torrent-specific attribute filters
  138. // ['Field name' => 'Search expression', ...]
  139. private $UsedTorrentAttrs = [];
  140. // Storage for used torrent-specific fulltext fields
  141. // ['Field name' => 'Search expression', ...]
  142. private $UsedTorrentFields = [];
  143. /**
  144. * Initialize and configure a TorrentSearch object
  145. *
  146. * @param bool $GroupResults whether results should be grouped by group id
  147. * @param string $OrderBy attribute to use for sorting the results
  148. * @param string $OrderWay Whether to use ascending or descending order
  149. * @param int $Page Page number to display
  150. * @param int $PageSize Number of results per page
  151. */
  152. public function __construct($GroupResults, $OrderBy, $OrderWay, $Page, $PageSize)
  153. {
  154. if ($GroupResults && !isset(self::$SortOrdersGrouped[$OrderBy])
  155. || !$GroupResults && !isset(self::$SortOrders[$OrderBy])
  156. || !in_array($OrderWay, ['asc', 'desc'])
  157. ) {
  158. global $Debug;
  159. $ErrMsg = "TorrentSearch constructor arguments:\n" . print_r(func_get_args(), true);
  160. $Debug->analysis('Bad arguments in TorrentSearch constructor', $ErrMsg, 3600*24);
  161. error('-1');
  162. }
  163. if (!is_number($Page) || $Page < 1) {
  164. $Page = 1;
  165. }
  166. if (check_perms('site_search_many')) {
  167. $this->Page = $Page;
  168. } else {
  169. $this->Page = min($Page, SPHINX_MAX_MATCHES / $PageSize);
  170. }
  171. $ResultLimit = $PageSize;
  172. $this->PageSize = $PageSize;
  173. $this->GroupResults = $GroupResults;
  174. $this->SphQL = new SphinxqlQuery();
  175. $this->SphQL->where_match('_all', 'fake', false);
  176. if ($OrderBy === 'random') {
  177. $this->SphQL->select('id, groupid')
  178. ->order_by('RAND()', '');
  179. $this->Random = true;
  180. $this->Page = 1;
  181. if ($GroupResults) {
  182. // Get more results because ORDER BY RAND() can't be used in GROUP BY queries
  183. $ResultLimit *= 5;
  184. }
  185. } elseif ($GroupResults) {
  186. $Select = 'groupid';
  187. if (isset(self::$AggregateExp[$OrderBy])) {
  188. $Select .= ', ' . self::$AggregateExp[$OrderBy];
  189. }
  190. $this->SphQL->select($Select)
  191. ->group_by('groupid')
  192. ->order_group_by(self::$SortOrdersGrouped[$OrderBy], $OrderWay)
  193. ->order_by(self::$SortOrdersGrouped[$OrderBy], $OrderWay);
  194. } else {
  195. $this->SphQL->select('id, groupid')
  196. ->order_by(self::$SortOrders[$OrderBy], $OrderWay);
  197. }
  198. $Offset = ($this->Page - 1) * $ResultLimit;
  199. $MinMax = G::$Cache->get_value('sphinx_min_max_matches');
  200. $MaxMatches = max($Offset + $ResultLimit, $MinMax ? $MinMax : 1000); # todo: Keep an eye on this
  201. $this->SphQL->from('torrents, delta')
  202. ->limit($Offset, $ResultLimit, $MaxMatches);
  203. }
  204. /**
  205. * Process search terms and run the main query
  206. *
  207. * @param array $Terms Array containing all search terms (e.g. $_GET)
  208. * @return array List of matching group IDs with torrent ID as key for ungrouped results
  209. */
  210. public function query($Terms = [])
  211. {
  212. $this->process_search_terms($Terms);
  213. $this->build_query();
  214. $this->run_query();
  215. $this->process_results();
  216. return $this->SphResults;
  217. }
  218. public function insert_hidden_tags($tags)
  219. {
  220. $this->SphQL->where_match($tags, 'taglist', false);
  221. }
  222. /**
  223. * Internal function that runs the queries needed to get the desired results
  224. */
  225. private function run_query()
  226. {
  227. $SphQLResult = $this->SphQL->query();
  228. if ($SphQLResult->Errno > 0) {
  229. $this->SphResults = false;
  230. return;
  231. }
  232. if ($this->Random && $this->GroupResults) {
  233. $TotalCount = $SphQLResult->get_meta('total_found');
  234. $this->SphResults = $SphQLResult->collect('groupid');
  235. $GroupIDs = array_keys($this->SphResults);
  236. $GroupCount = count($GroupIDs);
  237. while ($SphQLResult->get_meta('total') < $TotalCount && $GroupCount < $this->PageSize) {
  238. // Make sure we get $PageSize results, or all of them if there are less than $PageSize hits
  239. $this->SphQL->where('groupid', $GroupIDs, true);
  240. $SphQLResult = $this->SphQL->query();
  241. if (!$SphQLResult->has_results()) {
  242. break;
  243. }
  244. $this->SphResults += $SphQLResult->collect('groupid');
  245. $GroupIDs = array_keys($this->SphResults);
  246. $GroupCount = count($GroupIDs);
  247. }
  248. if ($GroupCount > $this->PageSize) {
  249. $this->SphResults = array_slice($this->SphResults, 0, $this->PageSize, true);
  250. }
  251. $this->NumResults = count($this->SphResults);
  252. } else {
  253. $this->NumResults = (int)$SphQLResult->get_meta('total_found');
  254. if ($this->GroupResults) {
  255. $this->SphResults = $SphQLResult->collect('groupid');
  256. } else {
  257. $this->SphResults = $SphQLResult->to_pair('id', 'groupid');
  258. }
  259. }
  260. }
  261. /**
  262. * Process search terms and store the parts in appropriate arrays until we know if
  263. * the NOT operator can be used
  264. */
  265. private function build_query()
  266. {
  267. foreach ($this->Terms as $Field => $Words) {
  268. $SearchString = '';
  269. if (isset(self::$FormsToFields[$Field])) {
  270. $Field = self::$FormsToFields[$Field];
  271. }
  272. $QueryParts = ['include' => [], 'exclude' => []];
  273. if (!empty($Words['include'])) {
  274. foreach ($Words['include'] as $Word) {
  275. $QueryParts['include'][] = Sphinxql::sph_escape_string($Word);
  276. }
  277. }
  278. if (!empty($Words['exclude'])) {
  279. foreach ($Words['exclude'] as $Word) {
  280. $QueryParts['exclude'][] = '!' . Sphinxql::sph_escape_string(substr($Word, 1));
  281. }
  282. }
  283. if (!empty($QueryParts)) {
  284. if (isset($Words['operator'])) {
  285. // Is the operator already specified?
  286. $Operator = $Words['operator'];
  287. } elseif (isset(self::$FieldOperators[$Field])) {
  288. // Does this field have a non-standard operator?
  289. $Operator = self::$FieldOperators[$Field];
  290. } else {
  291. // Go for the default operator
  292. $Operator = self::$FieldOperators[''];
  293. }
  294. if (!empty($QueryParts['include'])) {
  295. if ($Field === 'taglist') {
  296. foreach ($QueryParts['include'] as $key => $Tag) {
  297. $QueryParts['include'][$key] = '( '.$Tag.' | '.$Tag.':* )';
  298. }
  299. }
  300. $SearchString .= '( ' . implode($Operator, $QueryParts['include']) . ' ) ';
  301. }
  302. if (!empty($QueryParts['exclude'])) {
  303. $SearchString .= implode(' ', $QueryParts['exclude']);
  304. }
  305. $this->SphQL->where_match($SearchString, $Field, false);
  306. if (isset(self::$TorrentFields[$Field])) {
  307. $this->UsedTorrentFields[$Field] = $SearchString;
  308. }
  309. $this->Filtered = true;
  310. }
  311. }
  312. }
  313. /**
  314. * Look at each search term and figure out what to do with it
  315. *
  316. * @param array $Terms Array with search terms from query()
  317. */
  318. private function process_search_terms($Terms)
  319. {
  320. foreach ($Terms as $Key => $Term) {
  321. if (isset(self::$Fields[$Key])) {
  322. $this->process_field($Key, $Term);
  323. } elseif (isset(self::$Attributes[$Key])) {
  324. $this->process_attribute($Key, $Term);
  325. }
  326. $this->RawTerms[$Key] = $Term;
  327. }
  328. $this->post_process();
  329. }
  330. /**
  331. * Process attribute filters and store them in case we need to post-process grouped results
  332. *
  333. * @param string $Attribute Name of the attribute to filter against
  334. * @param mixed $Value The filter's condition for a match
  335. */
  336. private function process_attribute($Attribute, $Value)
  337. {
  338. if ($Value === '') {
  339. return;
  340. }
  341. if ($Attribute === 'year') {
  342. $this->search_year($Value);
  343. } elseif ($Attribute === 'size_unit') {
  344. // For the record, size_unit must appear in the GET parameters after size_min and size_max for this to work. Sorry.
  345. if (is_numeric($this->RawTerms['size_min']) || is_numeric($this->RawTerms['size_max'])) {
  346. $this->SphQL->where_between('size', [intval(($this->RawTerms['size_min'] ?? 0)*(1024**$Value)), intval(min(PHP_INT_MAX, ($this->RawTerms['size_max'] ?? INF)*(1024**$Value)))]);
  347. }
  348. } elseif ($Attribute === 'freetorrent') {
  349. if ($Value === 3) {
  350. $this->SphQL->where('freetorrent', 0, true);
  351. $this->UsedTorrentAttrs['freetorrent'] = 3;
  352. } elseif ($Value >= 0 && $Value < 3) {
  353. $this->SphQL->where('freetorrent', $Value);
  354. $this->UsedTorrentAttrs[$Attribute] = $Value;
  355. }
  356. } elseif ($Attribute === 'filter_cat') {
  357. if (!is_array($Value)) {
  358. $Value = array_fill_keys(explode('|', $Value), 1);
  359. }
  360. $CategoryFilter = [];
  361. foreach (array_keys($Value) as $Category) {
  362. if (is_number($Category)) {
  363. $CategoryFilter[] = $Category;
  364. } else {
  365. global $Categories;
  366. $ValidValues = array_map('strtolower', $Categories);
  367. if (($CategoryID = array_search(strtolower($Category), $ValidValues)) !== false) {
  368. $CategoryFilter[] = $CategoryID + 1;
  369. }
  370. }
  371. }
  372. $this->SphQL->where('categoryid', ($CategoryFilter ?? 0));
  373. } else {
  374. if (!is_number($Value) && self::$Attributes[$Attribute] !== false) {
  375. // Check if the submitted value can be converted to a valid one
  376. $ValidValuesVarname = self::$Attributes[$Attribute];
  377. global $$ValidValuesVarname;
  378. $ValidValues = array_map('strtolower', $$ValidValuesVarname);
  379. if (($Value = array_search(strtolower($Value), $ValidValues)) === false) {
  380. // Force the query to return 0 results if value is still invalid
  381. $Value = max(array_keys($ValidValues)) + 1;
  382. }
  383. }
  384. $this->SphQL->where($Attribute, $Value);
  385. $this->UsedTorrentAttrs[$Attribute] = $Value;
  386. }
  387. $this->Filtered = true;
  388. }
  389. /**
  390. * Look at a fulltext search term and figure out if it needs special treatment
  391. *
  392. * @param string $Field Name of the search field
  393. * @param string $Term Search expression for the field
  394. */
  395. private function process_field($Field, $Term)
  396. {
  397. $Term = trim($Term);
  398. if ($Term === '') {
  399. return;
  400. }
  401. if ($Field === 'searchstr') {
  402. $this->search_basic($Term);
  403. } elseif ($Field === 'filelist') {
  404. $this->search_filelist($Term);
  405. } elseif ($Field === 'taglist') {
  406. $this->search_taglist($Term);
  407. } else {
  408. $this->add_field($Field, $Term);
  409. }
  410. }
  411. /**
  412. * Some fields may require post-processing
  413. */
  414. private function post_process()
  415. {
  416. if (isset($this->Terms['taglist'])) {
  417. // Replace bad tags with tag aliases
  418. $this->Terms['taglist'] = Tags::remove_aliases($this->Terms['taglist']);
  419. if (isset($this->RawTerms['tags_type']) && (int)$this->RawTerms['tags_type'] === self::TAGS_ANY) {
  420. $this->Terms['taglist']['operator'] = self::SPH_BOOL_OR;
  421. }
  422. // Update the RawTerms array so get_terms() can return the corrected search terms
  423. if (isset($this->Terms['taglist']['include'])) {
  424. $AllTags = $this->Terms['taglist']['include'];
  425. } else {
  426. $AllTags = [];
  427. }
  428. if (isset($this->Terms['taglist']['exclude'])) {
  429. $AllTags = array_merge($AllTags, $this->Terms['taglist']['exclude']);
  430. }
  431. $this->RawTerms['taglist'] = str_replace('_', '.', implode(', ', $AllTags));
  432. }
  433. }
  434. /**
  435. * Handle magic keywords in the basic torrent search
  436. *
  437. * @param string $Term Given search expression
  438. */
  439. private function search_basic($Term)
  440. {
  441. global $Bitrates, $Formats, $Media;
  442. #$SearchBitrates = array_map('strtolower', $Bitrates);
  443. #array_push($SearchBitrates, 'v0', 'v1', 'v2', '24bit');
  444. #$SearchFormats = array_map('strtolower', $Formats);
  445. $SearchMedia = array_map('strtolower', $Media);
  446. foreach (explode(' ', $Term) as $Word) {
  447. /*
  448. if (in_array($Word, $SearchBitrates)) {
  449. $this->add_word('encoding', $Word);
  450. } elseif (in_array($Word, $SearchFormats)) {
  451. $this->add_word('format', $Word);
  452. }
  453. */
  454. if (in_array($Word, $SearchMedia)) {
  455. $this->add_word('media', $Word);
  456. } else {
  457. $this->add_word('searchstr', $Word);
  458. }
  459. }
  460. }
  461. /**
  462. * Use phrase boundary for file searches to make sure we don't count
  463. * partial hits from multiple files
  464. *
  465. * @param string $Term Given search expression
  466. */
  467. private function search_filelist($Term)
  468. {
  469. $SearchString = '"' . Sphinxql::sph_escape_string($Term) . '"~20';
  470. $this->SphQL->where_match($SearchString, 'filelist', false);
  471. $this->UsedTorrentFields['filelist'] = $SearchString;
  472. $this->Filtered = true;
  473. }
  474. /**
  475. * Prepare tag searches before sending them to the normal treatment
  476. *
  477. * @param string $Term Given search expression
  478. */
  479. private function search_taglist($Term)
  480. {
  481. $Term = strtr($Term, '.', '_');
  482. $this->add_field('taglist', $Term);
  483. }
  484. /**
  485. * The year filter accepts a range. Figure out how to handle the filter value
  486. *
  487. * @param string $Term Filter condition. Can be an integer or a range with the format X-Y
  488. * @return bool True if parameters are valid
  489. */
  490. private function search_year($Term)
  491. {
  492. $Years = explode('-', $Term);
  493. if (count($Years) === 1 && is_number($Years[0])) {
  494. // Exact year
  495. $this->SphQL->where('year', $Years[0]);
  496. } elseif (count($Years) === 2) {
  497. if (empty($Years[0]) && is_number($Years[1])) {
  498. // Range: 0 - 2005
  499. $this->SphQL->where_lt('year', $Years[1], true);
  500. } elseif (empty($Years[1]) && is_number($Years[0])) {
  501. // Range: 2005 - 2^32-1
  502. $this->SphQL->where_gt('year', $Years[0], true);
  503. } elseif (is_number($Years[0]) && is_number($Years[1])) {
  504. // Range: 2005 - 2009
  505. $this->SphQL->where_between('year', [min($Years), max($Years)]);
  506. } else {
  507. // Invalid input
  508. return false;
  509. }
  510. } else {
  511. // Invalid input
  512. return false;
  513. }
  514. return true;
  515. }
  516. /**
  517. * Add a field filter that doesn't need special treatment
  518. *
  519. * @param string $Field Name of the search field
  520. * @param string $Term Search expression for the field
  521. */
  522. private function add_field($Field, $Term)
  523. {
  524. if (isset(self::$FieldSeparators[$Field])) {
  525. $Separator = self::$FieldSeparators[$Field];
  526. } else {
  527. $Separator = self::$FieldSeparators[''];
  528. }
  529. $Words = explode($Separator, $Term);
  530. foreach ($Words as $Word) {
  531. $this->add_word($Field, $Word);
  532. }
  533. }
  534. /**
  535. * Add a keyword to the array of search terms
  536. *
  537. * @param string $Field Name of the search field
  538. * @param string $Word Keyword
  539. */
  540. private function add_word($Field, $Word)
  541. {
  542. $Word = trim($Word);
  543. // Skip isolated hyphens to enable "Artist - Title" searches
  544. if ($Word === '' || $Word === '-') {
  545. return;
  546. }
  547. if ($Word[0] === '!' && strlen($Word) >= 2 && strpos($Word, '!', 1) === false) {
  548. $this->Terms[$Field]['exclude'][] = $Word;
  549. } else {
  550. $this->Terms[$Field]['include'][] = $Word;
  551. }
  552. }
  553. /**
  554. * @return array Torrent group information for the matches from Torrents::get_groups
  555. */
  556. public function get_groups()
  557. {
  558. return $this->Groups;
  559. }
  560. /**
  561. * @param string $Type Field or attribute name
  562. * @return string Unprocessed search terms
  563. */
  564. public function get_terms($Type)
  565. {
  566. return $this->RawTerms[$Type] ?? '';
  567. }
  568. /**
  569. * @return int Result count
  570. */
  571. public function record_count()
  572. {
  573. return $this->NumResults;
  574. }
  575. /**
  576. * @return bool Whether any filters were used
  577. */
  578. public function has_filters()
  579. {
  580. return $this->Filtered;
  581. }
  582. /**
  583. * @return bool Whether any torrent-specific fulltext filters were used
  584. */
  585. public function need_torrent_ft()
  586. {
  587. return $this->GroupResults && $this->NumResults > 0 && !empty($this->UsedTorrentFields);
  588. }
  589. /**
  590. * Get torrent group info and remove any torrents that don't match
  591. */
  592. private function process_results()
  593. {
  594. if (count($this->SphResults) === 0) {
  595. return;
  596. }
  597. $this->Groups = Torrents::get_groups($this->SphResults);
  598. if ($this->need_torrent_ft()) {
  599. // Query Sphinx for torrent IDs if torrent-specific fulltext filters were used
  600. $this->filter_torrents_sph();
  601. } elseif ($this->GroupResults) {
  602. // Otherwise, let PHP discard unmatching torrents
  603. $this->filter_torrents_internal();
  604. }
  605. // Ungrouped searches don't need any additional filtering
  606. }
  607. /**
  608. * Build and run a query that gets torrent IDs from Sphinx when fulltext filters
  609. * were used to get primary results and they are grouped
  610. */
  611. private function filter_torrents_sph()
  612. {
  613. $AllTorrents = [];
  614. foreach ($this->Groups as $GroupID => $Group) {
  615. if (!empty($Group['Torrents'])) {
  616. $AllTorrents += array_fill_keys(array_keys($Group['Torrents']), $GroupID);
  617. }
  618. }
  619. $TorrentCount = count($AllTorrents);
  620. $this->SphQLTor = new SphinxqlQuery();
  621. $this->SphQLTor->where_match('_all', 'fake', false);
  622. $this->SphQLTor->select('id')->from('torrents, delta');
  623. foreach ($this->UsedTorrentFields as $Field => $Term) {
  624. $this->SphQLTor->where_match($Term, $Field, false);
  625. }
  626. $this->SphQLTor->copy_attributes_from($this->SphQL);
  627. $this->SphQLTor->where('id', array_keys($AllTorrents))->limit(0, $TorrentCount, $TorrentCount);
  628. $SphQLResultTor = $this->SphQLTor->query();
  629. $MatchingTorrentIDs = $SphQLResultTor->to_pair('id', 'id');
  630. foreach ($AllTorrents as $TorrentID => $GroupID) {
  631. if (!isset($MatchingTorrentIDs[$TorrentID])) {
  632. unset($this->Groups[$GroupID]['Torrents'][$TorrentID]);
  633. }
  634. }
  635. }
  636. /**
  637. * Non-Sphinx method of collecting IDs of torrents that match any
  638. * torrent-specific attribute filters that were used in the search query
  639. */
  640. private function filter_torrents_internal()
  641. {
  642. foreach ($this->Groups as $GroupID => $Group) {
  643. if (empty($Group['Torrents'])) {
  644. continue;
  645. }
  646. foreach ($Group['Torrents'] as $TorrentID => $Torrent) {
  647. if (!$this->filter_torrent_internal($Torrent)) {
  648. unset($this->Groups[$GroupID]['Torrents'][$TorrentID]);
  649. }
  650. }
  651. }
  652. }
  653. /**
  654. * Post-processing to determine if a torrent is a real hit or if it was
  655. * returned because another torrent in the group matched. Only used if
  656. * there are no torrent-specific fulltext conditions
  657. *
  658. * @param array $Torrent Torrent array, probably from Torrents::get_groups()
  659. * @return bool True if it's a real hit
  660. */
  661. private function filter_torrent_internal($Torrent)
  662. {
  663. if (isset($this->UsedTorrentAttrs['freetorrent'])) {
  664. $FilterValue = $this->UsedTorrentAttrs['freetorrent'];
  665. if ($FilterValue === '3' && $Torrent['FreeTorrent'] === '0') {
  666. // Either FL or NL is ok
  667. return false;
  668. } elseif ($FilterValue !== '3' && $FilterValue !== (int)$Torrent['FreeTorrent']) {
  669. return false;
  670. }
  671. }
  672. return true;
  673. }
  674. }