416 lines
15 KiB
PHP
416 lines
15 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
$cutoff = now()->subDays(30)->toDateString();
|
|
|
|
return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) {
|
|
return Artwork::search('')
|
|
->options([
|
|
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
|
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at: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);
|
|
}
|
|
}
|