Files
SkinbaseNova/app/Services/ArtworkSearchService.php

395 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
/**
* High-level search API powered by Meilisearch via Laravel Scout.
*
* No Meili calls in controllers — always go through this service.
*/
final class ArtworkSearchService
{
private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes
/**
* Full-text search with optional filters.
*
* Supported $filters keys:
* tags array<string> — 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;
}
return Artwork::search($q ?: '')
->options($options)
->paginate($perPage);
}
/**
* Load artworks for a tag page, sorted by views + likes descending.
*/
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
return $this->emptyPaginator($perPage);
}
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
'sort' => ['views:desc', 'likes:desc'],
])
->paginate($perPage);
});
}
/**
* Load artworks for a category, sorted by created_at desc.
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
])
->paginate($perPage);
});
}
// ── 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', 'created_at:desc'],
'fresh' => ['created_at: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' => ['created_at: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.{$categorySlug}.{$sort}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
->paginate($perPage);
});
}
/**
* 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.{$contentTypeSlug}.{$sort}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
->paginate($perPage);
});
}
// -------------------------------------------------------------------------
/**
* 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}";
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([
'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.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'likes:desc'],
])
->paginate($perPage);
});
}
/**
* Most recent artworks by created_at.
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at: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);
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
])
->paginate($perPage);
});
}
/**
* Fresh: newest uploads first.
*/
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at:desc'],
])
->paginate($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.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
])
->paginate($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.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
])
->paginate($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.' . md5(implode(',', $tagSlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate($limit);
});
}
/**
* Fresh artworks in given categories, sorted by created_at 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.' . md5(implode(',', $categorySlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['created_at:desc'],
])
->paginate($limit);
});
}
// -------------------------------------------------------------------------
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 emptyPaginator(int $perPage): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
}
}