showPasswords = app(ReleaseBrowseService::class)->showPasswords(); } /** * Get movie releases with covers for movie browse page. * * Uses three separate queries instead of GROUP_CONCAT: * 1. COUNT query for total results (replaces SQL_CALC_FOUND_ROWS) * 2. Paginated movie list with only needed columns * 3. Top 2 releases per movie using UNION ALL with LIMIT 2 per imdbid * * @param array $cat * @param array $excludedCats */ public function getMovieRange(int $page, array $cat, int $start, int $num, string $orderBy, int $maxAge = -1, array $excludedCats = []): mixed { $page = max(1, $page); $start = max(0, $start); // Build effective category filter: merge inclusion and exclusion into a single IN clause // to avoid redundant IN + NOT IN predicates and help the optimizer $catArray = []; if (count($cat) > 0 && $cat[0] !== -1) { // @phpstan-ignore offsetAccess.notFound $catArray = (array) (Category::getCategorySearch($cat, null, true) ?? []); } if (! empty($catArray) && ! empty($excludedCats)) { $catArray = array_values(array_diff($catArray, array_map('intval', $excludedCats))); } if (! empty($catArray)) { $catFilter = ' AND r.categories_id IN ('.implode(',', $catArray).') '; $whereExcluded = ''; } else { $catFilter = ''; $whereExcluded = count($excludedCats) > 0 ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''; } $order = $this->getMovieOrder($orderBy); $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); $whereAge = $maxAge > 0 ? 'AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ' : ''; $skipBrowseLikeFields = []; $indexImdbClause = ''; if (Search::isAvailable()) { $fieldTerms = []; foreach (['title', 'director', 'actors', 'genre'] as $field) { if (request()->has($field)) { $value = request()->input($field); if (is_array($value)) { continue; } $value = stripslashes((string) $value); if ($value !== '') { $fieldTerms[$field] = $value; } } } if ($fieldTerms !== []) { $found = Search::searchMoviesByFields($fieldTerms, 8000); if ($found['imdbids'] === []) { return collect(); } $quoted = array_map(static fn (string $id): string => escapeString($id), $found['imdbids']); $indexImdbClause = ' AND m.imdbid IN ('.implode(',', $quoted).') '; $skipBrowseLikeFields = ['title', 'director', 'actors', 'genre']; } } $browseBy = $this->getBrowseBy($skipBrowseLikeFields); $baseWhere = "m.title != '' AND m.imdbid IS NOT NULL AND m.imdbid != '' " ."AND r.passwordstatus {$this->showPasswords} " .$browseBy.' ' .$indexImdbClause .$catFilter .$whereAge .$whereExcluded; // Build a cache key from all the query parameters $cacheKey = md5('movie_range_'.$baseWhere.$order[0].$order[1].$start.$num.$page); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } // Step 1: Count total distinct movies matching filters. // Cached separately with a longer TTL (30 min) since the total changes slowly // and this query scans all 84K+ movieinfo rows joined to releases (~0.5s). $countCacheKey = md5('movie_count_'.$baseWhere); $totalCount = Cache::get($countCacheKey); if ($totalCount === null) { $countSql = 'SELECT COUNT(DISTINCT m.imdbid) AS total ' .'FROM movieinfo m ' .'INNER JOIN releases r ON r.imdbid = m.imdbid ' .'WHERE '.$baseWhere; $totalResult = DB::select($countSql); $totalCount = $totalResult[0]->total ?? 0; Cache::put($countCacheKey, $totalCount, now()->addMinutes(30)); } if ($totalCount === 0) { return collect(); } // Step 2: Get paginated movie list using two-phase subquery. // Inner query aggregates by just imdbid (small temp table, fast filesort on ~53K // narrow rows instead of 53K wide rows with TEXT columns like plot/genre/actors). // Outer query joins back to movieinfo for full details on only the top N movies. $isAggregateOrder = ($order[0] === 'MAX(r.postdate)'); if ($isAggregateOrder) { $innerOrderBy = 'latest_postdate'; $innerExtraGroupBy = ''; $outerOrderBy = 'stats.latest_postdate'; } else { // orderField is like 'm.title', 'm.year', 'm.rating' $innerOrderBy = $order[0]; $innerExtraGroupBy = ', '.$order[0]; $outerOrderBy = $order[0]; } $moviesSql = 'SELECT m.imdbid, m.tmdbid, m.traktid, m.title, m.year, m.rating, ' .'m.plot, m.genre, m.director, m.actors, m.cover, ' .'stats.latest_postdate, stats.total_releases ' .'FROM (' .'SELECT m.imdbid, MAX(r.postdate) AS latest_postdate, COUNT(r.id) AS total_releases ' .'FROM movieinfo m ' .'INNER JOIN releases r ON r.imdbid = m.imdbid ' .'WHERE '.$baseWhere.' ' .'GROUP BY m.imdbid'.$innerExtraGroupBy.' ' ."ORDER BY {$innerOrderBy} {$order[1]} " ."LIMIT {$num} OFFSET {$start}" .') stats ' .'INNER JOIN movieinfo m ON m.imdbid = stats.imdbid ' ."ORDER BY {$outerOrderBy} {$order[1]}"; $movies = MovieInfo::fromQuery($moviesSql); if ($movies->isEmpty()) { return collect(); } // Build list of movie IMDB IDs for release query $movieImdbIds = $movies->pluck('imdbid')->toArray(); // Step 3: Get top 2 releases per movie using UNION ALL with LIMIT 2 per imdbid. $unionParts = []; foreach ($movieImdbIds as $id) { $quotedId = escapeString((string) $id); $unionParts[] = '(SELECT r.id, r.imdbid, r.guid, r.searchname, ' .'r.size, r.postdate, r.adddate, r.haspreview ' .'FROM releases r ' .'WHERE r.imdbid = '.$quotedId.' ' ."AND r.passwordstatus {$this->showPasswords} " .$catFilter .$whereExcluded .$whereAge .'ORDER BY r.postdate DESC LIMIT 2)'; } $releasesSql = implode(' UNION ALL ', $unionParts); $releases = DB::select($releasesSql); // Group by imdbid $releasesByMovie = []; foreach ($releases as $release) { $releasesByMovie[$release->imdbid][] = $release; } // Attach releases to each movie foreach ($movies as $movie) { $movie->releases = $releasesByMovie[$movie->imdbid] ?? []; // @phpstan-ignore assign.propertyReadOnly } // Set total count on first item (matches existing pattern used by controllers) if ($movies->isNotEmpty()) { $movies[0]->_totalcount = $totalCount; // @phpstan-ignore property.notFound } Cache::put($cacheKey, $movies, $expiresAt); return $movies; } /** * Get all releases for a single movie by IMDB ID. * * @param array $excludedCats * @return array */ public function getMovieReleases(string $imdbid, array $excludedCats = []): array { $whereExcluded = count($excludedCats) > 0 ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''; $quotedId = escapeString($imdbid); $sql = 'SELECT r.id, r.guid, r.searchname, r.size, r.postdate, r.adddate, r.haspreview ' .'FROM releases r ' .'WHERE r.imdbid = '.$quotedId.' ' ."AND r.passwordstatus {$this->showPasswords} " .$whereExcluded.' ' .'ORDER BY r.postdate DESC'; return DB::select($sql); } /** * Get the order type the user requested on the movies page. * * @return array{0: string, 1: string} */ protected function getMovieOrder(string $orderBy): array { $orderArr = explode('_', (($orderBy === '') ? 'MAX(r.postdate)' : $orderBy)); $orderField = match ($orderArr[0]) { 'title' => 'm.title', 'year' => 'm.year', 'rating' => 'm.rating', default => 'MAX(r.postdate)', }; return [$orderField, isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; } /** * Order types for movies page. * * @return array */ public function getMovieOrdering(): array { return ['title_asc', 'title_desc', 'year_asc', 'year_desc', 'rating_asc', 'rating_desc']; } /** * @param array $skipFields Request keys to ignore (e.g. already applied via search index). */ protected function getBrowseBy(array $skipFields = []): string { $browseBy = ' '; $browseByArr = ['title', 'director', 'actors', 'genre', 'rating', 'year', 'imdb']; foreach ($browseByArr as $bb) { if ($skipFields !== [] && in_array($bb, $skipFields, true)) { continue; } if (request()->has($bb) && ! empty(request()->input($bb))) { $bbv = request()->input($bb); if (is_array($bbv)) { continue; } $bbv = stripslashes((string) $bbv); if ($bb === 'year') { if (preg_match('/^(19|20)\d{2}$/', $bbv)) { $browseBy .= ' AND m.year = '.escapeString($bbv); } continue; } if ($bb === 'rating') { $bbv .= '.'; } if ($bb === 'imdb') { $browseBy .= ' AND m.imdbid = '.escapeString($bbv); } else { $browseBy .= ' AND m.'.$bb.' '.'LIKE '.escapeString('%'.$bbv.'%'); } } } return $browseBy; } /** * Get IMDB genres. * * @return array */ public function getGenres(): array { return [ 'Action', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime', 'Documentary', 'Drama', 'Family', 'Fantasy', 'Film-Noir', 'Game-Show', 'History', 'Horror', 'Music', 'Musical', 'Mystery', 'News', 'Reality-TV', 'Romance', 'Sci-Fi', 'Sport', 'Talk-Show', 'Thriller', 'War', 'Western', ]; } }