feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
/**
* Configure the Meilisearch artworks index:
* sortable attributes (all fields used in category/discover sorts)
* filterable attributes (used in search filters)
*
* Run after any schema / toSearchableArray change:
* php artisan meilisearch:configure-index
*/
class ConfigureMeilisearchIndex extends Command
{
protected $signature = 'meilisearch:configure-index {--index=artworks : Meilisearch index name}';
protected $description = 'Push sortable and filterable attribute settings to the Meilisearch artworks index.';
/**
* Fields that can be used as sort targets in Artwork::search()->options(['sort' => ]).
* Must match keys in Artwork::toSearchableArray().
*/
private const SORTABLE_ATTRIBUTES = [
'created_at',
'trending_score_24h',
'trending_score_7d',
'favorites_count',
'downloads_count',
'awards_received_count',
'views',
'likes',
'downloads',
];
/**
* Fields used in filter expressions (AND category = "" etc.).
*/
private const FILTERABLE_ATTRIBUTES = [
'is_public',
'is_approved',
'category',
'content_type',
'tags',
'author_id',
'orientation',
'resolution',
];
public function handle(): int
{
$indexName = (string) $this->option('index');
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);
$index = $client->index($indexName);
$this->info("Configuring Meilisearch index: {$indexName}");
// ── Sortable attributes ───────────────────────────────────────────────
$this->line(' → Updating sortableAttributes…');
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
$this->line(" Task uid: {$task['taskUid']}");
// ── Filterable attributes ─────────────────────────────────────────────
$this->line(' → Updating filterableAttributes…');
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
$this->line(" Task uid: {$task2['taskUid']}");
$this->info('Done. Meilisearch will process these tasks asynchronously.');
$this->warn('Re-index artworks if sortable attributes changed: php artisan artworks:search-rebuild');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Copy views and downloads from the legacy `wallz` table into `artwork_stats`.
*
* Uses wallz.id as artwork_id.
* Rows that already exist are updated; missing rows are inserted with zeros
* for all other counters.
*
* Usage:
* php artisan skinbase:migrate-wallz-stats
* php artisan skinbase:migrate-wallz-stats --chunk=500 --dry-run
*/
class MigrateWallzStatsCommand extends Command
{
protected $signature = 'skinbase:migrate-wallz-stats
{--chunk=1000 : Number of wallz rows to process per batch}
{--dry-run : Preview counts without writing to the database}';
protected $description = 'Import views and downloads from legacy wallz table into artwork_stats';
public function handle(): int
{
$chunkSize = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No data will be written.');
}
$total = (int) DB::connection('legacy')->table('wallz')->count();
$processed = 0;
$inserted = 0;
$updated = 0;
$this->info("Found {$total} rows in legacy wallz table. Chunk size: {$chunkSize}.");
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — ins: %message%');
$bar->setMessage('0 ins / 0 upd');
$bar->start();
DB::connection('legacy')
->table('wallz')
->select('id', 'views', 'dls', 'rating', 'rating_num')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($dryRun, &$processed, &$inserted, &$updated, $bar) {
$artworkIds = $rows->pluck('id')->all();
// Find which artwork_ids already have a stats row.
$existing = DB::table('artwork_stats')
->whereIn('artwork_id', $artworkIds)
->pluck('artwork_id')
->flip(); // flip → [artwork_id => index] for O(1) lookup
$toInsert = [];
$now = now()->toDateTimeString();
foreach ($rows as $row) {
$views = max(0, (int) $row->views);
$dls = max(0, (int) $row->dls);
$ratingAvg = max(0, (float) $row->rating);
$ratingCount = max(0, (int) $row->rating_num);
if ($existing->has($row->id)) {
// Update existing row.
if (! $dryRun) {
DB::table('artwork_stats')
->where('artwork_id', $row->id)
->update([
'views' => $views,
'downloads' => $dls,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
]);
}
$updated++;
} else {
// Batch-collect for insert.
$toInsert[] = [
'artwork_id' => $row->id,
'views' => $views,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => $dls,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
];
$inserted++;
}
}
if (! $dryRun && ! empty($toInsert)) {
DB::table('artwork_stats')->insertOrIgnore($toInsert);
}
$processed += count($rows);
$bar->setMessage("{$inserted} ins / {$updated} upd");
$bar->advance(count($rows));
});
$bar->finish();
$this->newLine();
if ($dryRun) {
$this->warn("DRY RUN complete — would insert {$inserted}, update {$updated} ({$processed} rows scanned).");
} else {
$this->info("Done — inserted {$inserted}, updated {$updated} ({$processed} rows processed).");
}
return self::SUCCESS;
}
}

View File

@@ -13,6 +13,8 @@ use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
class Kernel extends ConsoleKernel
@@ -51,6 +53,12 @@ class Kernel extends ConsoleKernel
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
// ── Ranking system (rank_v1) ────────────────────────────────────────
// Step 1: compute per-artwork scores every hour at :05
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
// Step 2: build ranked lists every hour at :15 (after scores are ready)
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
}
/**

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* RankController
*
* Serves pre-computed ranked artwork lists.
*
* Endpoints:
* GET /api/rank/global?type=trending|new_hot|best
* GET /api/rank/category/{id}?type=trending|new_hot|best
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
*/
class RankController extends Controller
{
public function __construct(private readonly RankingService $ranking) {}
/**
* GET /api/rank/global
*
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
*/
public function global(Request $request): AnonymousResourceCollection|JsonResponse
{
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('global', null, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/category/{id}
*/
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
{
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
return response()->json(['message' => 'Category not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('category', $id, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/type/{contentType}
*
* {contentType} is accepted as either a slug (string) or numeric id.
*/
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
{
$ct = is_numeric($contentType)
? ContentType::find((int) $contentType)
: ContentType::where('slug', $contentType)->first();
if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('content_type', $ct->id, $listType);
return $this->buildResponse($result, $listType);
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Validate and normalise the ?type query param.
* Defaults to 'trending'.
*/
private function resolveListType(Request $request): string
{
$allowed = ['trending', 'new_hot', 'best'];
$type = $request->query('type', 'trending');
return in_array($type, $allowed, true) ? $type : 'trending';
}
/**
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
*
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
*/
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
{
$ids = $result['ids'];
$artworks = collect();
if (! empty($ids)) {
// Single whereIn query — no N+1
$keyed = Artwork::whereIn('id', $ids)
->with([
'user:id,name',
'categories' => function ($q): void {
$q->select(
'categories.id',
'categories.content_type_id',
'categories.parent_id',
'categories.name',
'categories.slug',
'categories.sort_order'
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->get()
->keyBy('id');
// Restore the ranked order
$artworks = collect($ids)
->filter(fn ($id) => $keyed->has($id))
->map(fn ($id) => $keyed[$id]);
}
$collection = ArtworkListResource::collection($artworks);
// Attach ranking meta as additional data
$collection->additional([
'meta' => [
'list_type' => $listType,
'computed_at' => $result['computed_at'],
'model_version' => $result['model_version'],
'fallback' => $result['fallback'],
'count' => $artworks->count(),
],
]);
return $collection;
}
}

View File

@@ -5,10 +5,12 @@ namespace App\Http\Controllers\Web;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Artwork;
use App\Services\ArtworkService;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\AbstractCursorPaginator;
@@ -16,11 +18,56 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
/**
* Meilisearch sort-field arrays per sort alias.
* First element is primary sort; subsequent elements are tie-breakers.
*/
private const SORT_MAP = [
'latest' => 'created_at:desc',
'popular' => 'views:desc',
'liked' => 'likes:desc',
'downloads' => 'downloads:desc',
// ── Nova sort aliases ─────────────────────────────────────────────────
// trending_score_24h only covers artworks ≤ 7 days old; use 7d score
// and favorites_count as fallbacks so older artworks don't all tie at 0.
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
// "New & Hot": 30-day trending window surfaces recently-active artworks.
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', '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'],
// ── Legacy aliases (backward compat) ──────────────────────────────────
'latest' => ['created_at:desc'],
'popular' => ['views:desc', 'favorites_count:desc'],
'liked' => ['likes:desc', 'favorites_count:desc'],
'downloads' => ['downloads:desc', 'downloads_count:desc'],
];
/**
* Cache TTL (seconds) per sort alias.
* trending 5 min
* fresh 2 min
* top-rated 10 min
* others 5 min
*/
private const SORT_TTL_MAP = [
'trending' => 300,
'fresh' => 120,
'top-rated' => 600,
'favorited' => 300,
'downloaded' => 300,
'oldest' => 600,
'latest' => 120,
'popular' => 300,
'liked' => 300,
'downloads' => 300,
];
/** Human-readable sort options passed to every gallery view. */
private const SORT_OPTIONS = [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🆕 Fresh'],
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
['value' => 'favorited', 'label' => '❤️ Most Favorited'],
['value' => 'downloaded', 'label' => '⬇ Most Downloaded'],
['value' => 'oldest', 'label' => '📅 Oldest'],
];
public function __construct(
@@ -31,34 +78,43 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function browse(Request $request)
{
$sort = (string) $request->query('sort', 'latest');
$sort = $this->resolveSort($request, 'trending');
$perPage = $this->resolvePerPage($request);
$page = (int) $request->query('page', 1);
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$artworks = Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
])->paginate($perPage);
$artworks = Cache::remember(
"browse.all.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
$mainCategories = $this->mainCategories();
return view('gallery.index', [
'gallery_type' => 'browse',
'mainCategories' => $mainCategories,
'subcategories' => $mainCategories,
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'hero_title' => 'Browse Artworks',
'gallery_type' => 'browse',
'mainCategories' => $mainCategories,
'subcategories' => $mainCategories,
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Browse Artworks',
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
'breadcrumbs' => collect(),
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
'breadcrumbs' => collect(),
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.",
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
@@ -74,37 +130,47 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404);
}
$sort = (string) $request->query('sort', 'latest');
// Default sort: trending (not chronological)
$sort = $this->resolveSort($request, 'trending');
$perPage = $this->resolvePerPage($request);
$page = (int) $request->query('page', 1);
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$mainCategories = $this->mainCategories();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
$artworks = Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
])->paginate($perPage);
$artworks = Cache::remember(
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
return view('gallery.index', [
'gallery_type' => 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $rootCategories,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
'hero_title' => $contentType->name,
'gallery_type' => 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $rootCategories,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]),
'page_title' => $contentType->name,
'page_meta_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]),
'page_title' => $contentType->name . ' Skinbase Nova',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
@@ -114,10 +180,16 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404);
}
$artworks = Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND category = "' . $category->slug . '"',
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
])->paginate($perPage);
$catSlug = $category->slug;
$artworks = Cache::remember(
"gallery.cat.{$catSlug}.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true AND category = "' . $catSlug . '"',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
@@ -134,22 +206,24 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
});
return view('gallery.index', [
'gallery_type' => 'category',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'contentType' => $contentType,
'category' => $category,
'artworks' => $artworks,
'hero_title' => $category->name,
'gallery_type' => 'category',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'contentType' => $contentType,
'category' => $category,
'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name,
'page_meta_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name . ' Skinbase Nova',
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
@@ -211,16 +285,53 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return redirect($target, 301);
}
private function presentArtwork(Artwork $artwork): object
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
private function resolvePerPage(Request $request): int
{
$limit = (int) $request->query('limit', 0);
$perPage = (int) $request->query('per_page', 0);
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 40);
// Spec §8: recommended 24 per page on category/gallery pages
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24);
return max(12, min($value, 80));
}
/**
* Validate and return the requested sort alias, falling back to $default.
* Only allows keys present in SORT_MAP.
*/
private function resolveSort(Request $request, string $default = 'trending'): string
{
$requested = (string) $request->query('sort', $default);
return array_key_exists($requested, self::SORT_MAP) ? $requested : $default;
}
private function mainCategories(): Collection
{
return ContentType::orderBy('id')

View File

@@ -38,7 +38,7 @@ final class DiscoverController extends Controller
{
$perPage = 24;
$results = $this->searchService->discoverTrending($perPage);
$artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
'artworks' => $results,
@@ -55,7 +55,7 @@ final class DiscoverController extends Controller
{
$perPage = 24;
$results = $this->searchService->discoverFresh($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
'artworks' => $results,
@@ -72,7 +72,7 @@ final class DiscoverController extends Controller
{
$perPage = 24;
$results = $this->searchService->discoverTopRated($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
'artworks' => $results,
@@ -89,7 +89,7 @@ final class DiscoverController extends Controller
{
$perPage = 24;
$results = $this->searchService->discoverMostDownloaded($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
'artworks' => $results,
@@ -110,7 +110,11 @@ final class DiscoverController extends Controller
$artworks = Artwork::query()
->public()
->published()
->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year])
@@ -206,16 +210,27 @@ final class DiscoverController extends Controller
$artworkItems = $feedResult['data'] ?? [];
// Build a simple presentable collection
$artworks = collect($artworkItems)->map(fn (array $item) => (object) [
'id' => $item['id'] ?? 0,
'name' => $item['title'] ?? 'Untitled',
'category_name' => '',
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_url'] ?? null,
'uname' => $item['author'] ?? 'Artist',
'published_at' => null,
'slug' => $item['slug'] ?? '',
]);
$artworks = collect($artworkItems)->map(function (array $item) {
$width = isset($item['width']) && $item['width'] > 0 ? (int) $item['width'] : null;
$height = isset($item['height']) && $item['height'] > 0 ? (int) $item['height'] : null;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($item['author_id'] ?? 0), null, 64);
return (object) [
'id' => $item['id'] ?? 0,
'name' => $item['title'] ?? 'Untitled',
'category_name' => $item['category_name'] ?? '',
'category_slug' => $item['category_slug'] ?? '',
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_url'] ?? null,
'uname' => $item['author'] ?? 'Artist',
'username' => $item['username'] ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $item['published_at'] ?? null,
'slug' => $item['slug'] ?? '',
'width' => $width,
'height' => $height,
];
});
$meta = $feedResult['meta'] ?? [];
$nextCursor = $meta['next_cursor'] ?? null;
@@ -308,10 +323,73 @@ final class DiscoverController extends Controller
// ─── Helpers ─────────────────────────────────────────────────────────────
private function hydrateDiscoverSearchResults($paginator): void
{
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
return;
}
$items = $paginator->getCollection();
if (!$items || $items->isEmpty()) {
return;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return;
}
$byId = Artwork::query()
->whereIn('id', $ids)
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->get()
->keyBy('id');
$paginator->setCollection(
$items->map(function ($item) use ($byId) {
$id = (int) ($item->id ?? 0);
$full = $id > 0 ? $byId->get($id) : null;
if ($full instanceof Artwork) {
return $this->presentArtwork($full);
}
return (object) [
'id' => $item->id ?? 0,
'name' => $item->title ?? $item->name ?? 'Untitled',
'category_name' => $item->category_name ?? $item->category ?? '',
'category_slug' => $item->category_slug ?? '',
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
'thumb_srcset' => $item->thumb_srcset ?? null,
'uname' => $item->author ?? $item->uname ?? 'Skinbase',
'username' => $item->username ?? '',
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64),
'published_at' => $item->published_at ?? null,
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
];
})
);
}
private function presentArtwork(Artwork $artwork): object
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
@@ -322,6 +400,7 @@ final class DiscoverController extends Controller
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\RankList;
use App\Services\RankingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankBuildListsJob
*
* Runs hourly (after RankComputeArtworkScoresJob).
*
* Builds ordered artwork_id arrays for:
* global trending, new_hot, best
* each category trending, new_hot, best
* each content_type trending, new_hot, best
*
* Applies author-diversity cap (max 3 per author in a list of 50).
* Stores results in rank_lists and busts relevant Redis keys.
*/
class RankBuildListsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800;
public int $tries = 2;
private const LIST_TYPES = ['trending', 'new_hot', 'best'];
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
$listSize = (int) config('ranking.diversity.list_size', 50);
$candidatePool = (int) config('ranking.diversity.candidate_pool', 200);
$listsBuilt = 0;
// ── 1. Global ──────────────────────────────────────────────────────
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'global', 0,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
// ── 2. Per category ────────────────────────────────────────────────
Category::query()
->select(['id'])
->where('is_active', true)
->orderBy('id')
->chunk(200, function ($categories) use (
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
): void {
foreach ($categories as $cat) {
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'category', $cat->id,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
}
});
// ── 3. Per content type ────────────────────────────────────────────
ContentType::query()
->select(['id'])
->orderBy('id')
->chunk(50, function ($ctypes) use (
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
): void {
foreach ($ctypes as $ct) {
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'content_type', $ct->id,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
}
});
Log::info('RankBuildListsJob: finished', [
'lists_built' => $listsBuilt,
'model_version' => $modelVersion,
]);
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Fetch candidates, apply diversity, and upsert the resulting list.
*/
private function buildAndStore(
RankingService $ranking,
string $listType,
string $scopeType,
int $scopeId,
string $modelVersion,
int $maxPerAuthor,
int $listSize,
int $candidatePool
): void {
$scoreCol = $this->scoreColumn($listType);
$candidates = $this->fetchCandidates(
$scopeType, $scopeId, $scoreCol, $candidatePool, $modelVersion
);
$diverse = $ranking->applyDiversity(
$candidates->all(), $maxPerAuthor, $listSize
);
$ids = array_map(
fn ($item) => (int) ($item->artwork_id ?? $item['artwork_id']),
$diverse
);
// Upsert the list (unique: scope_type + scope_id + list_type + model_version)
DB::table('rank_lists')->upsert(
[[
'scope_type' => $scopeType,
'scope_id' => $scopeId,
'list_type' => $listType,
'model_version' => $modelVersion,
'artwork_ids' => json_encode($ids),
'computed_at' => now()->toDateTimeString(),
]],
['scope_type', 'scope_id', 'list_type', 'model_version'],
['artwork_ids', 'computed_at']
);
// Bust Redis cache so next request picks up the new list
$ranking->bustCache($scopeType, $scopeId === 0 ? null : $scopeId, $listType);
}
/**
* Fetch top N candidates (with user_id) for a given scope/score column.
*
* @return \Illuminate\Support\Collection<int, object>
*/
private function fetchCandidates(
string $scopeType,
int $scopeId,
string $scoreCol,
int $limit,
string $modelVersion
): \Illuminate\Support\Collection {
$query = DB::table('rank_artwork_scores as ras')
->select(['ras.artwork_id', 'a.user_id', "ras.{$scoreCol}"])
->join('artworks as a', function ($join): void {
$join->on('a.id', '=', 'ras.artwork_id')
->where('a.is_public', 1)
->where('a.is_approved', 1)
->whereNull('a.deleted_at');
})
->where('ras.model_version', $modelVersion)
->orderByDesc("ras.{$scoreCol}")
->limit($limit);
if ($scopeType === 'category' && $scopeId > 0) {
$query->join(
'artwork_category as ac',
fn ($j) => $j->on('ac.artwork_id', '=', 'a.id')
->where('ac.category_id', $scopeId)
);
}
if ($scopeType === 'content_type' && $scopeId > 0) {
$query->join(
'artwork_category as ac',
'ac.artwork_id', '=', 'a.id'
)->join(
'categories as cat',
fn ($j) => $j->on('cat.id', '=', 'ac.category_id')
->where('cat.content_type_id', $scopeId)
->whereNull('cat.deleted_at')
);
}
return $query->get();
}
/**
* Map list_type to the rank_artwork_scores column name.
*/
private function scoreColumn(string $listType): string
{
return match ($listType) {
'new_hot' => 'score_new_hot',
'best' => 'score_best',
default => 'score_trending',
};
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\RankingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankComputeArtworkScoresJob
*
* Runs hourly. Queries raw artwork signals (views, favourites, downloads,
* age, tags) in batches, computes the three ranking scores using
* RankingService::computeScores(), and bulk-upserts the results into
* rank_artwork_scores.
*
* No N+1: all signals are resolved via a single pre-aggregated JOIN query,
* chunked by artwork id.
*/
class RankComputeArtworkScoresJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 min max
public int $tries = 2;
private const CHUNK_SIZE = 500;
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$total = 0;
$now = now()->toDateTimeString();
$ranking->artworkSignalsQuery()
->orderBy('a.id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ranking, $modelVersion, $now, &$total): void {
$rows = collect($rows);
if ($rows->isEmpty()) {
return;
}
$upserts = $rows->map(function ($row) use ($ranking, $modelVersion, $now): array {
$scores = $ranking->computeScores($row);
return [
'artwork_id' => (int) $row->id,
'score_trending' => $scores['score_trending'],
'score_new_hot' => $scores['score_new_hot'],
'score_best' => $scores['score_best'],
'model_version' => $modelVersion,
'computed_at' => $now,
];
})->all();
DB::table('rank_artwork_scores')->upsert(
$upserts,
['artwork_id'], // unique key
['score_trending', 'score_new_hot', 'score_best', // update these
'model_version', 'computed_at']
);
$total += count($upserts);
});
Log::info('RankComputeArtworkScoresJob: finished', [
'total_updated' => $total,
'model_version' => $modelVersion,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\RankArtworkScore
*
* Materialised ranking scores for a single artwork.
* Rebuilt hourly by RankComputeArtworkScoresJob.
*
* @property int $artwork_id
* @property float $score_trending
* @property float $score_new_hot
* @property float $score_best
* @property string $model_version
* @property \Carbon\Carbon|null $computed_at
*/
class RankArtworkScore extends Model
{
protected $table = 'rank_artwork_scores';
/** Artwork_id is the primary key; no auto-increment. */
protected $primaryKey = 'artwork_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'artwork_id',
'score_trending',
'score_new_hot',
'score_best',
'model_version',
'computed_at',
];
protected $casts = [
'artwork_id' => 'integer',
'score_trending' => 'float',
'score_new_hot' => 'float',
'score_best' => 'float',
'computed_at' => 'datetime',
];
// ── Relations ──────────────────────────────────────────────────────────
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Map list_type string to the corresponding score column.
*/
public static function scoreColumn(string $listType): string
{
return match ($listType) {
'new_hot' => 'score_new_hot',
'best' => 'score_best',
default => 'score_trending',
};
}
}

53
app/Models/RankList.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* App\Models\RankList
*
* Stores an ordered list of artwork IDs for a feed surface.
* Rebuilt hourly by RankBuildListsJob.
*
* scope_id = 0 is the sentinel for "global" scope.
*
* @property int $id
* @property string $scope_type global | category | content_type
* @property int $scope_id 0 = global; category.id or content_type.id otherwise
* @property string $list_type trending | new_hot | best
* @property string $model_version
* @property array $artwork_ids Ordered array of artwork IDs
* @property \Carbon\Carbon|null $computed_at
*/
class RankList extends Model
{
protected $table = 'rank_lists';
public $timestamps = false;
protected $fillable = [
'scope_type',
'scope_id',
'list_type',
'model_version',
'artwork_ids',
'computed_at',
];
protected $casts = [
'scope_id' => 'integer',
'artwork_ids' => 'array',
'computed_at' => 'datetime',
];
// ── Scope helpers ──────────────────────────────────────────────────────
/** Resolve scope_id: null → 0 (global sentinel). */
public static function resolveScope(?int $id): int
{
return $id ?? 0;
}
}

View File

@@ -113,6 +113,80 @@ final class ArtworkSearchService
});
}
// ── 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.

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\RankArtworkScore;
use App\Models\RankList;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankingService Skinbase Nova rank_v1
*
* Responsibilities:
* 1. Score computation turn raw artwork signals into three float scores.
* 2. Diversity filtering cap items per author while keeping rank order.
* 3. List read / cache serve ranked lists from Redis, falling back to DB,
* and ultimately to latest-first if no list is built yet.
*/
final class RankingService
{
// ── Score computation ──────────────────────────────────────────────────
/**
* Compute all three ranking scores for a single artwork data row.
*
* @param object $row stdClass with fields:
* views_7d, favourites_7d, downloads_7d,
* views_all, favourites_all, downloads_all,
* views_24h, favourites_24h, downloads_24h,
* age_hours, tag_count, has_thumbnail (bool 0/1),
* is_public, is_approved
* @return array{score_trending: float, score_new_hot: float, score_best: float}
*/
public function computeScores(object $row): array
{
$cfg = config('ranking');
$wV = (float) $cfg['weights']['views'];
$wF = (float) $cfg['weights']['favourites'];
$wD = (float) $cfg['weights']['downloads'];
// 3.1 Base engagement (7-day window)
$E = ($wV * log(1 + (float) $row->views_7d))
+ ($wF * log(1 + (float) $row->favourites_7d))
+ ($wD * log(1 + (float) $row->downloads_7d));
// Base engagement (all-time, for "best" score)
$E_all = ($wV * log(1 + (float) $row->views_all))
+ ($wF * log(1 + (float) $row->favourites_all))
+ ($wD * log(1 + (float) $row->downloads_all));
// 3.2 Freshness decay
$ageH = max(0.0, (float) $row->age_hours);
$decayTrending = exp(-$ageH / (float) $cfg['half_life']['trending']);
$decayNewHot = exp(-$ageH / (float) $cfg['half_life']['new_hot']);
$decayBest = exp(-$ageH / (float) $cfg['half_life']['best']);
// 3.3 Quality modifier
$tagCount = (int) $row->tag_count;
$hasTags = $tagCount > 0;
$hasThumb = (bool) $row->has_thumbnail;
$isVisible = (bool) $row->is_public && (bool) $row->is_approved;
$Q = 1.0;
if ($hasTags) { $Q += (float) $cfg['quality']['has_tags']; }
if ($hasThumb) { $Q += (float) $cfg['quality']['has_thumbnail']; }
$Q += (float) $cfg['quality']['tag_count_bonus']
* (min($tagCount, (int) $cfg['quality']['tag_count_max'])
/ (float) $cfg['quality']['tag_count_max']);
if (! $isVisible) { $Q -= (float) $cfg['quality']['penalty_hidden']; }
// 3.4 Novelty boost (New & Hot)
$noveltyW = (float) $cfg['novelty_weight'];
$novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0);
// Anti-spam damping on trending score only
$spamFactor = 1.0;
$spam = $cfg['spam'];
if (
(float) $row->views_24h > (float) $spam['views_24h_threshold']
&& (float) $row->views_24h > 0
) {
$rF = (float) $row->favourites_24h / (float) $row->views_24h;
$rD = (float) $row->downloads_24h / (float) $row->views_24h;
if ($rF < (float) $spam['fav_ratio_threshold']
&& $rD < (float) $spam['dl_ratio_threshold']
) {
$spamFactor = (float) $spam['trending_penalty_factor'];
}
}
$scoreTrending = $E * $decayTrending * (1.0 + $Q) * $spamFactor;
$scoreNewHot = $E * $decayNewHot * $novelty * (1.0 + $Q);
$scoreBest = $E_all * $decayBest * (1.0 + $Q);
return [
'score_trending' => max(0.0, $scoreTrending),
'score_new_hot' => max(0.0, $scoreNewHot),
'score_best' => max(0.0, $scoreBest),
];
}
// ── Diversity filtering ────────────────────────────────────────────────
/**
* Apply author-diversity cap to an already-ordered candidate array.
*
* @param array $candidates Ordered array, each element must have artwork_id + user_id.
* @param int $maxPerAuthor
* @param int $listSize
* @return array Filtered, at most $listSize elements.
*/
public function applyDiversity(array $candidates, int $maxPerAuthor, int $listSize): array
{
$result = [];
$authorCount = [];
foreach ($candidates as $item) {
$uid = (int) ($item->user_id ?? $item['user_id'] ?? 0);
if (($authorCount[$uid] ?? 0) >= $maxPerAuthor) {
continue;
}
$result[] = $item;
$authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1;
if (count($result) >= $listSize) {
break;
}
}
return $result;
}
// ── List retrieval ─────────────────────────────────────────────────────
/**
* Retrieve a ranked list of artwork IDs.
*
* Order of precedence:
* 1. Redis cache
* 2. rank_lists table
* 3. Fallback: latest-first from artworks
*
* @param string $scopeType global | category | content_type
* @param int|null $scopeId category.id or content_type.id, null for global
* @param string $listType trending | new_hot | best
* @return array{ids: int[], computed_at: string|null, model_version: string, fallback: bool}
*/
public function getList(string $scopeType, ?int $scopeId, string $listType): array
{
$ttl = (int) config('ranking.cache.ttl', 900);
$cacheKey = $this->cacheKey($scopeType, $scopeId, $listType);
$modelVer = config('ranking.model_version', 'rank_v1');
// 1. Cache
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
// 2. DB
$rankList = RankList::where('scope_type', $scopeType)
->where('scope_id', RankList::resolveScope($scopeId))
->where('list_type', $listType)
->where('model_version', $modelVer)
->first();
if ($rankList !== null) {
$payload = [
'ids' => $rankList->artwork_ids,
'computed_at' => $rankList->computed_at?->toIso8601String(),
'model_version' => $rankList->model_version,
'fallback' => false,
];
Cache::put($cacheKey, $payload, $ttl);
return $payload;
}
// 3. Fallback — latest published artworks
Log::info('RankingService: no rank list found, falling back to latest', [
'scope_type' => $scopeType,
'scope_id' => $scopeId,
'list_type' => $listType,
]);
$ids = $this->fallbackIds($scopeType, $scopeId);
return [
'ids' => $ids,
'computed_at' => null,
'model_version' => 'fallback',
'fallback' => true,
];
}
/**
* Bust the Redis cache for a specific scope/type combination.
*/
public function bustCache(string $scopeType, ?int $scopeId, string $listType): void
{
Cache::forget($this->cacheKey($scopeType, $scopeId, $listType));
}
/**
* Bust all cache keys for a list type across scopes.
* (Convenience used after full rebuild.)
*/
public function bustAllCaches(string $modelVersion): void
{
foreach (['trending', 'new_hot', 'best'] as $listType) {
Cache::forget($this->cacheKey('global', null, $listType));
}
// Category and content_type caches are keyed with scope_id, so they expire
// naturally after TTL or get replaced on next request.
}
/**
* Build the Redis cache key for a list.
*
* Format: rank:list:{scope_type}:{scope_id|global}:{list_type}:{model_version}
*/
public function cacheKey(string $scopeType, ?int $scopeId, string $listType): string
{
$prefix = config('ranking.cache.prefix', 'rank');
$version = config('ranking.model_version', 'rank_v1');
$sid = $scopeId !== null ? (string) $scopeId : 'global';
return "{$prefix}:list:{$scopeType}:{$sid}:{$listType}:{$version}";
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Latest-first fallback IDs (public, approved artworks).
* Applies category/content_type filter when relevant.
*
* @return int[]
*/
private function fallbackIds(string $scopeType, ?int $scopeId): array
{
$listSize = (int) config('ranking.diversity.list_size', 50);
$query = Artwork::query()
->select('artworks.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->whereNotNull('artworks.published_at')
->orderByDesc('artworks.published_at')
->limit($listSize);
if ($scopeType === 'category' && $scopeId !== null) {
$query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->where('artwork_category.category_id', $scopeId);
}
if ($scopeType === 'content_type' && $scopeId !== null) {
$query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
->where('categories.content_type_id', $scopeId);
}
return $query->pluck('artworks.id')->map(fn ($id) => (int) $id)->all();
}
// ── Signal query (used by RankComputeArtworkScoresJob) ─────────────────
/**
* Return a query builder that selects all artwork signals needed for score
* computation. Results are NOT paginated callers chunk them.
*
* Columns returned:
* id, user_id, published_at, is_public, is_approved,
* thumb_ext ( has_thumbnail),
* views_7d, downloads_7d, views_24h, downloads_24h,
* views_all, downloads_all, favourites_all,
* favourites_7d, favourites_24h, downloads_24h,
* tag_count,
* age_hours
*/
public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
{
return DB::table('artworks as a')
->select([
'a.id',
'a.user_id',
'a.published_at',
'a.is_public',
'a.is_approved',
DB::raw('(a.thumb_ext IS NOT NULL AND a.thumb_ext != "") AS has_thumbnail'),
DB::raw('COALESCE(ast.views_7d, 0) AS views_7d'),
DB::raw('COALESCE(ast.downloads_7d, 0) AS downloads_7d'),
DB::raw('COALESCE(ast.views_24h, 0) AS views_24h'),
DB::raw('COALESCE(ast.downloads_24h, 0) AS downloads_24h'),
DB::raw('COALESCE(ast.views, 0) AS views_all'),
DB::raw('COALESCE(ast.downloads, 0) AS downloads_all'),
DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'),
DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'),
DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'),
DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'),
DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
])
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'a.id')
// Favourites (7 days)
->leftJoinSub(
DB::table('artwork_favourites')
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 7 DAY)'))
->groupBy('artwork_id'),
'fav7',
'fav7.artwork_id', '=', 'a.id'
)
// Favourites (24 hours)
->leftJoinSub(
DB::table('artwork_favourites')
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
->groupBy('artwork_id'),
'fav1',
'fav1.artwork_id', '=', 'a.id'
)
// Tag count
->leftJoinSub(
DB::table('artwork_tag')
->select('artwork_id', DB::raw('COUNT(*) as tag_count'))
->groupBy('artwork_id'),
'tc',
'tc.artwork_id', '=', 'a.id'
)
->where('a.is_public', 1)
->where('a.is_approved', 1)
->whereNull('a.deleted_at')
->whereNotNull('a.published_at');
}
}

View File

@@ -69,7 +69,7 @@ final class TrendingService
->whereNotNull('published_at')
->where('published_at', '>=', $cutoff)
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void {
->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, &$updated): void {
$ids = $artworks->pluck('id')->toArray();
$inClause = implode(',', array_fill(0, count($ids), '?'));