published() ->with(['tags:id,slug', 'categories:id,slug']) ->find($id); if (! $artwork) { return response()->json(['error' => 'Artwork not found'], 404); } $cacheKey = "api.similar.{$artwork->id}"; $items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) { return $this->findSimilar($artwork); }); return response()->json(['data' => $items]); } private function findSimilar(Artwork $artwork): array { $tagSlugs = $artwork->tags->pluck('slug')->values()->all(); $categorySlugs = $artwork->categories->pluck('slug')->values()->all(); $orientation = $this->orientation($artwork); // Build Meilisearch filter: exclude self and same creator $filterParts = [ 'is_public = true', 'is_approved = true', 'id != ' . $artwork->id, 'author_id != ' . $artwork->user_id, ]; // Filter by same orientation (landscape/portrait) — improves visual coherence if ($orientation !== 'square') { $filterParts[] = 'orientation = "' . $orientation . '"'; } // Priority 1: tag overlap (OR match across tags) if ($tagSlugs !== []) { $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs )); $filterParts[] = '(' . $tagFilter . ')'; } elseif ($categorySlugs !== []) { // Fallback to category if no tags $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs )); $filterParts[] = '(' . $catFilter . ')'; } $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), 'sort' => ['trending_score_7d:desc', 'likes:desc'], ]) ->paginate(self::LIMIT); return $results->getCollection() ->map(fn (Artwork $a): array => [ 'id' => $a->id, 'title' => $a->title, 'slug' => $a->slug, 'thumb' => $a->thumbUrl('md'), 'url' => '/art/' . $a->id . '/' . $a->slug, 'author_id' => $a->user_id, 'orientation' => $this->orientation($a), 'width' => $a->width, 'height' => $a->height, ]) ->values() ->all(); } private function orientation(Artwork $artwork): string { if (! $artwork->width || ! $artwork->height) { return 'square'; } return match (true) { $artwork->width > $artwork->height => 'landscape', $artwork->height > $artwork->width => 'portrait', default => 'square', }; } }