— tag slugs (AND match) * category string * orientation string — landscape | portrait | square * resolution string — e.g. "1920x1080" * author_id int * sort string — created_at|downloads|likes|views (suffix :asc or :desc) */ public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator { $filterParts = [self::BASE_FILTER]; $sort = []; if (! empty($filters['tags'])) { foreach ((array) $filters['tags'] as $tag) { $filterParts[] = 'tags = "' . addslashes((string) $tag) . '"'; } } if (! empty($filters['category'])) { $filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"'; } if (! empty($filters['orientation'])) { $filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"'; } if (! empty($filters['resolution'])) { $filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"'; } if (! empty($filters['author_id'])) { $filterParts[] = 'author_id = ' . (int) $filters['author_id']; } if (! empty($filters['sort'])) { [$field, $dir] = $this->parseSort((string) $filters['sort']); if ($field) { $sort[] = $field . ':' . $dir; } } $options = ['filter' => implode(' AND ', $filterParts)]; if ($sort !== []) { $options['sort'] = $sort; } $results = Artwork::search($q ?: '') ->options($this->viewerAwareOptions($options)) ->paginate($perPage); if (! $this->shouldFallbackToViewerVisibilityFiltering($results)) { return $results; } $page = max(1, (int) request()->get('page', 1)); $candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page); $fallbackResults = Artwork::search($q ?: '') ->options($options) ->paginate($candidateCount, 'page', 1); $visibleItems = $this->filterSearchCollectionByCatalogVisibility($fallbackResults->getCollection()); $offset = max(0, ($page - 1) * $perPage); $slice = $visibleItems->slice($offset, $perPage)->values(); $visibleTotal = (int) ($fallbackResults->total() <= $candidateCount ? $visibleItems->count() : $fallbackResults->total()); return $this->makeModelPaginator($slice, $visibleTotal, $perPage, $page); } public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator { $page = max(1, $page ?? (int) request()->get('page', 1)); $candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page); $results = Artwork::search('') ->options($this->viewerAwareOptions($options)) ->paginate($candidateCount, 'page', 1); if ($this->shouldFallbackToViewerVisibilityFiltering($results)) { $results = Artwork::search('') ->options($options) ->paginate($candidateCount, 'page', 1); } $visibleItems = $this->filterSearchCollectionByCatalogVisibility($results->getCollection()); $ordered = $this->rerankSearchCollectionByThumbnailHealth($visibleItems, $excludeMissing); $offset = max(0, ($page - 1) * $perPage); $slice = $ordered->slice($offset, $perPage)->values(); return $this->makeModelPaginator($slice, (int) $results->total(), $perPage, $page); } /** * Load artworks for a tag page, sorted by views + likes descending. */ public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator { $tag = Tag::where('slug', $slug)->first(); if (! $tag) { return $this->emptyPaginator($perPage); } $sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular'; $cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) { $query = Artwork::query() ->public() ->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id)) ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') ->with(['user.profile', 'categories.contentType']); match ($sort) { 'likes' => $query ->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC') ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') ->orderByDesc('artworks.published_at'), 'latest' => $query ->orderByDesc('artworks.published_at') ->orderByDesc('artworks.id'), 'downloads' => $query ->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC') ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') ->orderByDesc('artworks.published_at'), default => $query ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') ->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC') ->orderByDesc('artworks.published_at'), }; return $query ->paginate($perPage) ->withQueryString(); }); } /** * Load artworks for a category, sorted by created_at desc. */ public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator { $page = (int) request()->get('page', 1); $cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page; return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"', 'sort' => ['created_at:desc'], ], $perPage, false, $page); }); } // ── Category / Content-Type page sorts ──────────────────────────────────── /** * Meilisearch sort fields per alias. * Used by categoryPageSort() and contentTypePageSort(). */ private const CATEGORY_SORT_FIELDS = [ 'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'], 'fresh' => ['published_at_ts:desc'], 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], 'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'], 'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'], 'oldest' => ['published_at_ts:asc'], ]; /** Cache TTL (seconds) per sort alias for category pages. */ private const CATEGORY_SORT_TTL = [ 'trending' => 300, // 5 min 'fresh' => 120, // 2 min 'top-rated' => 600, // 10 min 'favorited' => 300, 'downloaded' => 300, 'oldest' => 600, ]; /** * Artworks for a single category page, sorted via Meilisearch. * Default sort: trending (trending_score_24h:desc). * * Cache key pattern: category.{slug}.{sort}.{page} * TTL varies by sort (see spec: 5/2/10 min). */ public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator { $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}"; return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], ], $perPage, false, (int) request()->get('page', 1)); }); } /** * Artworks for a content-type root page, sorted via Meilisearch. * Default sort: trending. * * Cache key pattern: content_type.{slug}.{sort}.{page} */ public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator { $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}"; return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], ], $perPage, false, (int) request()->get('page', 1)); }); } // ------------------------------------------------------------------------- /** * Related artworks: same tags, different artwork, ranked by views + likes. * Limit 12. */ public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator { $tags = $artwork->tags()->pluck('tags.slug')->values()->all(); if ($tags === []) { return $this->popular($limit); } $cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}"; return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) { $tagFilters = implode(' OR ', array_map( fn ($t) => 'tags = "' . addslashes($t) . '"', $tags )); return Artwork::search('') ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')', 'sort' => ['views:desc', 'likes:desc'], ])) ->paginate($limit); }); } /** * Most popular artworks by views. */ public function popular(int $perPage = 24): LengthAwarePaginator { return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER, 'sort' => ['views:desc', 'likes:desc'], ])) ->paginate($perPage); }); } /** * Most recent artworks by publish timestamp. */ public function recent(int $perPage = 24): LengthAwarePaginator { return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Artwork::search('') ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER, 'sort' => ['published_at_ts:desc'], ])) ->paginate($perPage); }); } // ── Discover section helpers ─────────────────────────────────────────────── /** * Trending: sorted by Ranking Engine V2 `ranking_score` (recalculated every 30 min). * * Spec §6: Uses ranking_score, limits to last 30 days, * highlights high-velocity artworks via engagement_velocity tiebreaker. */ public function discoverTrending(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); $windowDays = $this->timeWindow->getTrendingWindowDays(30); $cutoff = now()->subDays($windowDays)->toDateString(); // Include window in cache key so adaptive expansions surface immediately $cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}"; return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], ], $perPage); }); } /** * Rising: sorted by heat_score (recalculated every 15 min). * * Surfaces artworks with rapid recent engagement growth. * Restricts to last 30 days, sorted by heat_score DESC. */ public function discoverRising(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); $windowDays = $this->timeWindow->getTrendingWindowDays(30); $cutoff = now()->subDays($windowDays)->toDateString(); $cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}"; return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'], ], $perPage); }); } /** * Fresh: newest uploads first. */ public function discoverFresh(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER, 'sort' => ['published_at_ts:desc'], ], $perPage); }); } /** * Top rated: highest number of favourites/likes. */ public function discoverTopRated(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER, 'sort' => ['likes:desc', 'views:desc'], ], $perPage); }); } /** * Most downloaded: highest download count. */ public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER, 'sort' => ['downloads:desc', 'views:desc'], ], $perPage); }); } /** * Artworks matching any of the given tag slugs, sorted by trending score. * Used for personalized "Because you like {tags}" homepage section. * * @param string[] $tagSlugs */ public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator { if (empty($tagSlugs)) { return $this->popular($limit); } $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', array_slice($tagSlugs, 0, 5) )); $cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs)); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', 'sort' => ['trending_score_7d:desc', 'likes:desc'], ], $limit, true, 1); }); } private function viewerAwareOptions(array $options): array { $options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user()); return $options; } private function shouldFallbackToViewerVisibilityFiltering(LengthAwarePaginator $results): bool { if ($results->total() > 0) { return false; } return $this->maturity->viewerPreferences(request()->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE; } private function viewerCacheSegment(): string { return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility']; } /** * Fresh artworks in given categories, sorted by publish timestamp desc. * Used for personalized "Fresh in your favourite categories" section. * * @param string[] $categorySlugs */ public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator { if (empty($categorySlugs)) { return $this->recent($limit); } $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', array_slice($categorySlugs, 0, 3) )); $cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs)); return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) { return $this->searchWithThumbnailPreference([ 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', 'sort' => ['published_at_ts:desc'], ], $limit, true, 1); }); } // ------------------------------------------------------------------------- private function parseSort(string $sort): array { $allowed = ['created_at', 'downloads', 'likes', 'views']; $parts = explode(':', $sort, 2); $field = $parts[0] ?? ''; $dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc'; return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc']; } private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection { if ($items->isEmpty()) { return $items; } $ids = $items ->pluck('id') ->filter(fn ($id) => is_numeric($id) && (int) $id > 0) ->map(fn ($id) => (int) $id) ->values(); if ($ids->isEmpty()) { return $items->values(); } $missingIds = Artwork::query() ->whereIn('id', $ids) ->where('has_missing_thumbnails', true) ->pluck('id') ->map(fn ($id) => (int) $id) ->flip(); if ($missingIds->isEmpty()) { return $items->values(); } $healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0))); if ($excludeMissing) { return $healthy->values(); } return $healthy ->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0)))) ->values(); } private function filterSearchCollectionByCatalogVisibility(Collection $items): Collection { if ($items->isEmpty()) { return $items; } $ids = $items ->pluck('id') ->filter(fn ($id) => is_numeric($id) && (int) $id > 0) ->map(fn ($id) => (int) $id) ->values(); if ($ids->isEmpty()) { return $items->values(); } $visibilityQuery = Artwork::query() ->catalogVisible() ->whereIn('id', $ids); $visibleIds = $this->maturity ->applyViewerFilter($visibilityQuery, request()->user()) ->pluck('id') ->map(fn ($id) => (int) $id) ->flip(); return $items ->filter(fn ($item) => $visibleIds->has((int) ($item->id ?? 0))) ->values(); } private function determineSearchCandidatePoolSize(int $perPage, int $page): int { return min( self::SEARCH_CANDIDATE_POOL_MAX, max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2)) ); } private function makeModelPaginator(Collection $items, int $total, int $perPage, int $page): LengthAwarePaginator { $paginator = new PaginationLengthAwarePaginator( [], $total, $perPage, $page, [ 'path' => request()->url(), 'query' => request()->query(), 'pageName' => 'page', ] ); return $paginator->setCollection(new EloquentCollection($items->all())); } private function emptyPaginator(int $perPage): LengthAwarePaginator { return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); } }