122 lines
3.9 KiB
PHP
122 lines
3.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Artwork;
|
|
use App\Services\ArtworkSearchService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* GET /api/art/{id}/similar
|
|
*
|
|
* Returns up to 12 similar artworks based on:
|
|
* 1. Tag overlap (primary signal)
|
|
* 2. Same category
|
|
* 3. Similar orientation
|
|
*
|
|
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
|
* Current artwork and its creator are excluded from results.
|
|
*/
|
|
final class SimilarArtworksController extends Controller
|
|
{
|
|
private const LIMIT = 12;
|
|
private const CACHE_TTL = 300; // 5 minutes
|
|
|
|
public function __construct(private readonly ArtworkSearchService $search) {}
|
|
|
|
public function __invoke(int $id): JsonResponse
|
|
{
|
|
$artwork = Artwork::public()
|
|
->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',
|
|
};
|
|
}
|
|
}
|