fallbackPublicCollections($safeLimit); } $seedIds = collect() ->merge(DB::table('collection_saves')->where('user_id', $user->id)->pluck('collection_id')) ->merge(DB::table('collection_likes')->where('user_id', $user->id)->pluck('collection_id')) ->merge(DB::table('collection_follows')->where('user_id', $user->id)->pluck('collection_id')) ->map(static fn ($id) => (int) $id) ->unique() ->values(); $followedCreatorIds = DB::table('user_followers') ->where('follower_id', $user->id) ->pluck('user_id') ->map(static fn ($id) => (int) $id) ->unique() ->values(); $seedCollections = $seedIds->isEmpty() ? collect() : Collection::query() ->publicEligible() ->whereIn('id', $seedIds->all()) ->get(['id', 'type', 'event_key', 'campaign_key', 'season_key', 'user_id']); $candidateQuery = Collection::query() ->publicEligible() ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->when($seedIds->isNotEmpty(), fn ($query) => $query->whereNotIn('id', $seedIds->all())) ->orderByDesc('ranking_score') ->orderByDesc('followers_count') ->orderByDesc('saves_count') ->orderByDesc('updated_at') ->limit(max(24, $safeLimit * 4)); if ($seedCollections->isEmpty() && $followedCreatorIds->isNotEmpty()) { $candidateQuery->whereIn('user_id', $followedCreatorIds->all()); } $candidates = $candidateQuery->get(); if ($candidates->isEmpty()) { return $this->fallbackPublicCollections($safeLimit); } $candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all(); $creatorMap = $this->creatorMap($candidateIds); $tagMap = $this->tagMap($candidateIds); $seedTypes = $seedCollections->pluck('type')->filter()->unique()->values()->all(); $seedCampaigns = $seedCollections->pluck('campaign_key')->filter()->unique()->values()->all(); $seedEvents = $seedCollections->pluck('event_key')->filter()->unique()->values()->all(); $seedSeasons = $seedCollections->pluck('season_key')->filter()->unique()->values()->all(); $seedCreatorIds = $seedIds->isEmpty() ? [] : collect($this->creatorMap($seedIds->all())) ->flatten() ->map(static fn ($id) => (int) $id) ->unique() ->values() ->all(); $seedTagSlugs = $seedIds->isEmpty() ? [] : $seedCollections ->map(fn (Collection $collection) => $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? [])) ->flatten() ->unique() ->values() ->all(); return new EloquentCollection($candidates ->map(function (Collection $candidate) use ($safeLimit, $seedTypes, $seedCampaigns, $seedEvents, $seedSeasons, $seedCreatorIds, $seedTagSlugs, $followedCreatorIds, $creatorMap, $tagMap): array { $candidateCreators = $creatorMap[(int) $candidate->id] ?? []; $candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []); $score = 0; $score += in_array($candidate->type, $seedTypes, true) ? 5 : 0; $score += ($candidate->campaign_key && in_array($candidate->campaign_key, $seedCampaigns, true)) ? 4 : 0; $score += ($candidate->event_key && in_array($candidate->event_key, $seedEvents, true)) ? 4 : 0; $score += ($candidate->season_key && in_array($candidate->season_key, $seedSeasons, true)) ? 3 : 0; $score += in_array((int) $candidate->user_id, $followedCreatorIds->all(), true) ? 6 : 0; $score += count(array_intersect($seedCreatorIds, $candidateCreators)) * 2; $score += count(array_intersect($seedTagSlugs, $candidateTags)); $score += $candidate->is_featured ? 2 : 0; $score += min(4, (int) floor(((int) $candidate->followers_count + (int) $candidate->saves_count) / 40)); $score += min(3, (int) floor((float) $candidate->ranking_score / 25)); return [ 'score' => $score, 'collection' => $candidate, ]; }) ->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0)) ->take($safeLimit) ->pluck('collection') ->values() ->all()); } public function relatedPublicCollections(Collection $collection, int $limit = 6): EloquentCollection { $safeLimit = max(1, min($limit, 12)); $candidates = Collection::query() ->publicEligible() ->where('id', '!=', $collection->id) ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->orderByDesc('is_featured') ->orderByDesc('followers_count') ->orderByDesc('saves_count') ->orderByDesc('updated_at') ->limit(30) ->get(); if ($candidates->isEmpty()) { return $candidates; } $candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all(); $creatorMap = $this->creatorMap($candidateIds); $tagMap = $this->tagMap($candidateIds); $currentCreatorIds = $this->creatorMap([(int) $collection->id])[(int) $collection->id] ?? []; $currentTagSlugs = $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? []); return new EloquentCollection($candidates ->map(function (Collection $candidate) use ($collection, $creatorMap, $tagMap, $currentCreatorIds, $currentTagSlugs): array { $candidateCreators = $creatorMap[(int) $candidate->id] ?? []; $candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []); $score = 0; $score += $candidate->type === $collection->type ? 4 : 0; $score += (int) $candidate->user_id === (int) $collection->user_id ? 3 : 0; $score += ($collection->event_key && $candidate->event_key === $collection->event_key) ? 4 : 0; $score += $candidate->is_featured ? 1 : 0; $score += count(array_intersect($currentCreatorIds, $candidateCreators)) * 2; $score += count(array_intersect($currentTagSlugs, $candidateTags)); $score += min(2, (int) floor(((int) $candidate->saves_count + (int) $candidate->followers_count) / 25)); return [ 'score' => $score, 'collection' => $candidate, ]; }) ->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0)) ->take($safeLimit) ->pluck('collection') ->values() ->all()); } /** * @param array $collectionIds * @return array> */ private function creatorMap(array $collectionIds): array { return DB::table('collection_artwork as ca') ->join('artworks as a', 'a.id', '=', 'ca.artwork_id') ->whereIn('ca.collection_id', $collectionIds) ->whereNull('a.deleted_at') ->select('ca.collection_id', 'a.user_id') ->get() ->groupBy('collection_id') ->map(fn ($rows) => collect($rows)->pluck('user_id')->map(static fn ($id) => (int) $id)->unique()->values()->all()) ->mapWithKeys(fn ($value, $key) => [(int) $key => $value]) ->all(); } /** * @param array $collectionIds * @return array> */ private function tagMap(array $collectionIds): array { return DB::table('collection_artwork as ca') ->join('artwork_tag as at', 'at.artwork_id', '=', 'ca.artwork_id') ->join('tags as t', 't.id', '=', 'at.tag_id') ->whereIn('ca.collection_id', $collectionIds) ->select('ca.collection_id', 't.slug') ->get() ->groupBy('collection_id') ->map(fn ($rows) => collect($rows)->pluck('slug')->map(static fn ($slug) => (string) $slug)->unique()->take(10)->values()->all()) ->mapWithKeys(fn ($value, $key) => [(int) $key => $value]) ->all(); } /** * @param array $tagSlugs * @return array */ private function signalTagSlugs(Collection $collection, array $tagSlugs): array { if (! $collection->isSmart() || ! is_array($collection->smart_rules_json)) { return $tagSlugs; } $ruleTags = collect($collection->smart_rules_json['rules'] ?? []) ->map(fn ($rule) => is_array($rule) ? ($rule['value'] ?? null) : null) ->filter(fn ($value) => is_string($value) && $value !== '') ->map(fn (string $value) => strtolower(trim($value))) ->take(10) ->all(); return array_values(array_unique(array_merge($tagSlugs, $ruleTags))); } private function fallbackPublicCollections(int $limit): EloquentCollection { return Collection::query() ->publicEligible() ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->orderByDesc('ranking_score') ->orderByDesc('followers_count') ->orderByDesc('updated_at') ->limit($limit) ->get(); } }