Extract related artwork lookup into a dedicated service.

Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results.
This commit is contained in:
2026-09-20 14:49:32 +02:00
parent 1053a38eee
commit a5e51617a6
3 changed files with 449 additions and 122 deletions
@@ -35,8 +35,8 @@ final class SimilarArtworksPageController extends Controller
private const QDRANT_LIMIT = 120;
public function __construct(
private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly HybridSimilarArtworksService $hybridService,
) {}
@@ -57,53 +57,53 @@ final class SimilarArtworksPageController extends Controller
$baseUrl = url("/art/{$id}/similar");
// ── Normalise source artwork for the view ──────────────────────────────
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceCard = (object) [
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_profile_url' => $source->user?->username ? '/@'.$source->user->username : null,
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
'browse_url' => $primaryCat?->contentType?->slug ? url('/'.$primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
];
return view('gallery.similar', [
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "'.$sourceTitle.'" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "'.$sourceTitle.'" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
@@ -130,37 +130,37 @@ final class SimilarArtworksPageController extends Controller
])
->findOrFail($id);
$page = max(1, (int) $request->query('page', 1));
$page = max(1, (int) $request->query('page', 1));
$baseUrl = url("/art/{$id}/similar");
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
])->values();
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
return response()->json([
'data' => $galleryItems,
'data' => $galleryItems,
'similarity_source' => $similaritySource,
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
]);
}
@@ -171,16 +171,13 @@ final class SimilarArtworksPageController extends Controller
*/
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
{
// Priority 1 — Qdrant visual (vision) similarity
// Priority 1 — Qdrant visual (vision) similarity.
// Paginate IDs before hydrating/presenting full Artwork models so a
// request only pays the relation/presenter cost for the current page.
if ($this->vectors->isConfigured()) {
$qdrantItems = $this->resolveViaQdrant($source);
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'visual'];
$qdrantPaginator = $this->resolveViaQdrant($source, $page, $baseUrl);
if ($qdrantPaginator !== null && $qdrantPaginator->total() > 0) {
return [$qdrantPaginator, 'visual'];
}
}
@@ -192,54 +189,106 @@ final class SimilarArtworksPageController extends Controller
$page,
$baseUrl,
);
return [$paginator, 'hybrid'];
}
// Priority 3 — Meilisearch tag/category overlap
$paginator = $this->meilisearchFallback($source, $page);
return [$paginator, 'tags'];
}
/**
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
* (so we have category/dimension data for the masonry grid).
* Query Qdrant via VectorGateway, preserve relevance order, then paginate
* candidate IDs before hydrating full Artwork models for the current page.
*
* Returns null when the gateway call fails, so the caller can fall through.
* The light eligibility query keeps the existing public/published semantics
* for cached Qdrant results without loading relations for every candidate.
* Returns null when the gateway call fails or no eligible candidates remain,
* allowing the caller to continue to the existing hybrid/tag fallbacks.
*/
private function resolveViaQdrant(Artwork $source): ?Collection
{
private function resolveViaQdrant(
Artwork $source,
int $page,
string $baseUrl,
): ?LengthAwarePaginator {
try {
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
} catch (RuntimeException) {
return null;
}
if (empty($raw)) {
if ($raw === []) {
return null;
}
// Preserve Qdrant relevance order; IDs are already filtered to public+published
$orderedIds = array_column($raw, 'id');
// Preserve Qdrant relevance order while normalising IDs defensively.
$orderedIds = collect(array_column($raw, 'id'))
->map(static fn ($id): int => is_numeric($id) ? (int) $id : 0)
->filter(static fn (int $id): bool => $id > 0 && $id !== (int) $source->id)
->unique()
->values();
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->where('id', '!=', $source->id) // belt-and-braces exclusion
if ($orderedIds->isEmpty()) {
return null;
}
// Keep cached Qdrant results honest if an artwork has since become
// private/unpublished. Fetch IDs only; do not hydrate relations yet.
$eligibleSet = Artwork::query()
->whereIn('id', $orderedIds->all())
->public()
->published()
->with([
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
])
->get()
->keyBy('id');
->pluck('id')
->mapWithKeys(static fn ($id): array => [(int) $id => true]);
return collect($orderedIds)
->map(fn (int $id) => $artworks->get($id))
->filter()
$eligibleIds = $orderedIds
->filter(static fn (int $id): bool => $eligibleSet->has($id))
->values();
if ($eligibleIds->isEmpty()) {
return null;
}
$perPage = self::PER_PAGE;
$total = $eligibleIds->count();
$pageIds = $eligibleIds->forPage($page, $perPage)->values();
$items = collect();
if ($pageIds->isNotEmpty()) {
$artworks = Artwork::query()
->whereIn('id', $pageIds->all())
->public()
->published()
->with([
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
])
->get()
->keyBy('id');
$items = $pageIds
->map(fn (int $id) => $artworks->get($id))
->filter()
->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
->values();
}
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => $baseUrl,
'query' => [],
],
);
}
/**
@@ -247,26 +296,26 @@ final class SimilarArtworksPageController extends Controller
*/
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
{
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$categorySlugs = $source->categories->pluck('slug')->values()->all();
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $source->id,
'id != '.$source->id,
];
if ($tagSlugs !== []) {
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
$quoted = array_map(fn (string $t): string => 'tags = "'.addslashes($t).'"', $tagSlugs);
$filterParts[] = '('.implode(' OR ', $quoted).')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
$quoted = array_map(fn (string $c): string => '(category = "'.addslashes($c).'" OR categories = "'.addslashes($c).'")', $categorySlugs);
$filterParts[] = '('.implode(' OR ', $quoted).')';
}
$results = Artwork::search('')->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->load([
@@ -287,15 +336,15 @@ final class SimilarArtworksPageController extends Controller
*/
private function paginateCollection(
Collection $items,
int $page,
string $path,
int $page,
string $path,
): LengthAwarePaginator {
$perPage = self::PER_PAGE;
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
'path' => $path,
'path' => $path,
'query' => [],
]);
}
@@ -304,9 +353,9 @@ final class SimilarArtworksPageController extends Controller
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
@@ -317,33 +366,33 @@ final class SimilarArtworksPageController extends Controller
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@'.$username : null);
return (object) $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
], $artwork, request()->user());
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class ArtworkRelatedService
{
private const SPLIT_PIVOT_ROW_THRESHOLD = 1000;
private const RELATIONS = ['user', 'group', 'categories.contentType'];
public function __construct(private readonly ArtworkMaturityService $maturity) {}
/**
* Return the related artwork models used by the artwork page.
*
* The fallback query is intentionally kept equivalent to the controller's
* original OR/EXISTS query. The split path only changes how candidates are
* found; the final models and their relations remain the same.
*/
public function related(Artwork $artwork, ?User $viewer, int $limit = 12): EloquentCollection
{
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
if ($categoryIds->isEmpty() || $this->matchingPivotRows($categoryIds) > self::SPLIT_PIVOT_ROW_THRESHOLD) {
return $this->originalQuery($artwork, $viewer, $categoryIds, $tagIds, $limit)->get();
}
$candidateRows = collect()
->merge($this->topAuthorCandidates($artwork, $viewer, $limit))
->merge($this->topGroupCandidates($artwork, $viewer, $limit))
->merge($this->topCategoryCandidates($artwork, $viewer, $categoryIds, $limit))
->merge($this->topTagCandidates($artwork, $viewer, $tagIds, $limit))
->reject(fn (object $row): bool => (int) $row->id === (int) $artwork->id)
->unique(fn (object $row): string => (string) $row->id)
->sortByDesc(fn (object $row): mixed => $row->published_at)
->take($limit)
->values();
$ids = $candidateRows->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if ($ids === []) {
return new EloquentCollection;
}
$hydrated = Artwork::query()
->with(self::RELATIONS)
->whereIn('id', $ids)
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
->get()
->keyBy('id');
return new EloquentCollection(collect($ids)
->map(fn (int $id): ?Artwork => $hydrated->get($id))
->filter()
->values()
->all());
}
private function matchingPivotRows(Collection $categoryIds): int
{
return (int) DB::table('artwork_category')
->whereIn('category_id', $categoryIds->all())
->count();
}
private function candidateQuery(Artwork $artwork, ?User $viewer): Builder
{
return Artwork::query()
->select(['artworks.id', 'artworks.published_at'])
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer));
}
private function topAuthorCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
{
return $this->candidateQuery($artwork, $viewer)
->where('user_id', $artwork->user_id)
->latest('published_at')
->limit($limit)
->get();
}
private function topGroupCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
{
if (! $artwork->group_id) {
return collect();
}
return $this->candidateQuery($artwork, $viewer)
->where('group_id', $artwork->group_id)
->latest('published_at')
->limit($limit)
->get();
}
private function topCategoryCandidates(Artwork $artwork, ?User $viewer, Collection $categoryIds, int $limit): Collection
{
return $this->candidateQuery($artwork, $viewer)
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->whereIn('artwork_category.category_id', $categoryIds->all())
->select(['artworks.id', 'artworks.published_at'])
->distinct()
->orderByDesc('artworks.published_at')
->limit($limit)
->get();
}
private function topTagCandidates(Artwork $artwork, ?User $viewer, Collection $tagIds, int $limit): Collection
{
if ($tagIds->isEmpty()) {
return collect();
}
return $this->candidateQuery($artwork, $viewer)
->whereHas('tags', fn ($query) => $query->whereIn('tags.id', $tagIds->all()))
->latest('published_at')
->limit($limit)
->get();
}
private function originalQuery(Artwork $artwork, ?User $viewer, Collection $categoryIds, Collection $tagIds, int $limit): Builder
{
return Artwork::query()
->with(self::RELATIONS)
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
if ($artwork->group_id) {
$query->orWhere('group_id', $artwork->group_id);
}
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
$categoryQuery->whereIn('categories.id', $categoryIds->all());
});
}
if ($tagIds->isNotEmpty()) {
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
$tagQuery->whereIn('tags.id', $tagIds->all());
});
}
})
->latest('published_at')
->limit($limit);
}
}