optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -9,9 +9,11 @@ use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendation\RecommendationService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -27,13 +29,22 @@ use Illuminate\Database\QueryException;
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug',
];
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
private readonly RecommendationService $reco,
private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller,
private readonly CollectionDiscoveryService $collectionDiscovery,
private readonly CollectionService $collectionService,
private readonly CollectionSurfaceService $collectionSurfaces,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -50,6 +61,10 @@ final class HomepageService
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
@@ -61,12 +76,12 @@ final class HomepageService
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. trending same trending feed as guests
* 4. by_tags artworks matching user's top tags (Trending For You)
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
* 2. from_following artworks from creators you follow
* 3. for_you personalized recommendation preview
* 4. trending same trending feed as guests
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
@@ -81,6 +96,11 @@ final class HomepageService
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_recent' => $this->getRecentCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
@@ -95,21 +115,127 @@ final class HomepageService
}
/**
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
*
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
* diversity controls, and its own Redis cache layer.
* "For You" homepage preview backed by the personalized feed engine.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
return $this->reco->forYouPreview($user, $limit);
$feed = $this->feedResolver->getFeed((int) $user->id, $limit);
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
$discoveryEndpoint = route('api.discovery.events.store');
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
return [
'id' => (int) ($item['id'] ?? 0),
'title' => (string) ($item['title'] ?? 'Untitled'),
'name' => (string) ($item['title'] ?? 'Untitled'),
'slug' => (string) ($item['slug'] ?? ''),
'author' => (string) ($item['author'] ?? 'Artist'),
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null,
'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
'width' => isset($item['width']) ? (int) $item['width'] : null,
'height' => isset($item['height']) ? (int) $item['height'] : null,
'published_at' => $item['published_at'] ?? null,
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => $reason,
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
'recommendation_surface' => 'homepage-for-you',
'discovery_endpoint' => $discoveryEndpoint,
'hide_artwork_endpoint' => $hideArtworkEndpoint,
'dislike_tag_endpoint' => $dislikeTagEndpoint,
'metric_badge' => [
'label' => $reason,
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
],
];
})->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
public function getTrendingCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicTrendingCollections($limit),
false
);
}
public function getEditorialCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
false
);
}
public function getCommunityCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
false
);
}
public function getFeaturedCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicFeaturedCollections($limit),
false,
);
}
public function getRecentCollections(int $limit = 6): array
{
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicRecentCollections($limit),
false,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
@@ -133,6 +259,10 @@ final class HomepageService
$artwork = null;
}
if ($artwork instanceof Artwork) {
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
@@ -156,13 +286,13 @@ final class HomepageService
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($results->isEmpty()) {
if ($items->isEmpty()) {
return $this->getRisingFromDb($limit);
}
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -183,7 +313,7 @@ final class HomepageService
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
@@ -216,13 +346,13 @@ final class HomepageService
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($results->isEmpty()) {
if ($items->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -244,7 +374,7 @@ final class HomepageService
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
@@ -270,7 +400,7 @@ final class HomepageService
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->orderByDesc('published_at')
->limit($limit)
->get();
@@ -523,7 +653,7 @@ final class HomepageService
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(10)
@@ -535,7 +665,7 @@ final class HomepageService
}
/**
* Artworks matching the user's top tags (max 12).
* Fresh artworks matching the user's favourite tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
@@ -546,8 +676,9 @@ final class HomepageService
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->searchResultCollection($results);
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -569,8 +700,9 @@ final class HomepageService
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->searchResultCollection($results);
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -584,6 +716,42 @@ final class HomepageService
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* @return Collection<int, Artwork>
*/
private function searchResultCollection(mixed $results): Collection
{
if ($results instanceof Collection) {
return $results;
}
if (is_object($results) && method_exists($results, 'getCollection')) {
$collection = $results->getCollection();
if ($collection instanceof Collection) {
return $collection;
}
}
return collect();
}
/**
* Ensure serialized artwork payloads do not trigger lazy-loading per item.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function prepareArtworksForSerialization(Collection $artworks): Collection
{
if ($artworks->isEmpty()) {
return $artworks;
}
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks;
}
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');