feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
79
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal file
79
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal 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;
|
||||
}
|
||||
}
|
||||
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal file
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
144
app/Http/Controllers/Api/RankController.php
Normal file
144
app/Http/Controllers/Api/RankController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
206
app/Jobs/RankBuildListsJob.php
Normal file
206
app/Jobs/RankBuildListsJob.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
79
app/Jobs/RankComputeArtworkScoresJob.php
Normal file
79
app/Jobs/RankComputeArtworkScoresJob.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Models/RankArtworkScore.php
Normal file
69
app/Models/RankArtworkScore.php
Normal 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
53
app/Models/RankList.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
342
app/Services/RankingService.php
Normal file
342
app/Services/RankingService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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), '?'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user