diff --git a/.env.example b/.env.example index 967bfff2..fea5f577 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,12 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}" # Upload UI feature flag (legacy upload remains default unless explicitly enabled) SKINBASE_UPLOADS_V2=false +# Upload transport tuning +UPLOAD_CHUNK_MAX_BYTES=5242880 +UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000 +UPLOAD_RATE_CHUNK_USER=180 +UPLOAD_RATE_CHUNK_IP=360 + # Draft abuse prevention controls SKINBASE_MAX_DRAFTS=10 SKINBASE_MAX_DRAFT_STORAGE_MB=1024 diff --git a/app/Console/Commands/ConfigureMeilisearchIndex.php b/app/Console/Commands/ConfigureMeilisearchIndex.php index ab3c7e5a..b56fbb59 100644 --- a/app/Console/Commands/ConfigureMeilisearchIndex.php +++ b/app/Console/Commands/ConfigureMeilisearchIndex.php @@ -26,6 +26,7 @@ class ConfigureMeilisearchIndex extends Command */ private const SORTABLE_ATTRIBUTES = [ 'created_at', + 'published_at_ts', 'trending_score_24h', 'trending_score_7d', 'favorites_count', diff --git a/app/Console/Commands/HealthCheckCommand.php b/app/Console/Commands/HealthCheckCommand.php new file mode 100644 index 00000000..28c22710 --- /dev/null +++ b/app/Console/Commands/HealthCheckCommand.php @@ -0,0 +1,419 @@ + [status, message, details]] */ + private array $results = []; + + public function handle(): int + { + $only = $this->option('only') ? strtolower((string) $this->option('only')) : null; + + $checks = [ + 'mysql' => fn () => $this->checkMysql(), + 'redis' => fn () => $this->checkRedis(), + 'cache' => fn () => $this->checkCache(), + 'meilisearch' => fn () => $this->checkMeilisearch(), + 'reverb' => fn () => $this->checkReverb(), + 'vision' => fn () => $this->checkVision(), + 'horizon' => fn () => $this->checkHorizon(), + 'app' => fn () => $this->checkApp(), + ]; + + if ($only !== null) { + if (! array_key_exists($only, $checks)) { + $this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks))); + return self::FAILURE; + } + $checks = [$only => $checks[$only]]; + } + + foreach ($checks as $name => $check) { + $check(); + } + + if ($this->option('json')) { + $this->line(json_encode($this->results, JSON_PRETTY_PRINT)); + return $this->hasFailures() ? self::FAILURE : self::SUCCESS; + } + + $this->renderTable(); + + $failed = $this->countByStatus('fail'); + $warned = $this->countByStatus('warn'); + + $this->newLine(); + if ($failed > 0) { + $this->error("❌ {$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.'); + return self::FAILURE; + } + if ($warned > 0) { + $this->warn("⚠️ All checks passed with {$warned} warning(s)."); + return self::SUCCESS; + } + $this->info('✅ All checks passed.'); + return self::SUCCESS; + } + + // ── Individual checks ────────────────────────────────────────────────────── + + private function checkMysql(): void + { + try { + DB::select('SELECT 1'); + $db = config('database.connections.' . config('database.default') . '.database'); + $artworkCount = DB::table('artworks')->whereNull('deleted_at')->count(); + $this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]); + } catch (Throwable $e) { + $this->failCheck('mysql', 'Connection failed: ' . $e->getMessage()); + } + } + + private function checkRedis(): void + { + try { + $pong = Redis::ping(); + // ping returns "+PONG\r\n" string or true depending on driver + $ok = $pong === true || str_contains((string) $pong, 'PONG'); + if ($ok) { + $info = Redis::info('server'); + $version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown'); + $this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]); + } else { + $this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true)); + } + } catch (Throwable $e) { + $this->failCheck('redis', 'Connection failed: ' . $e->getMessage()); + } + } + + private function checkCache(): void + { + try { + $key = '_healthcheck_' . uniqid('', true); + $value = 'ok_' . time(); + Cache::put($key, $value, 10); + $got = Cache::get($key); + Cache::forget($key); + + $driver = config('cache.default'); + if ($got === $value) { + $this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]); + } else { + $this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true)); + } + } catch (Throwable $e) { + $this->failCheck('cache', 'Cache test failed: ' . $e->getMessage()); + } + } + + private function checkMeilisearch(): void + { + try { + /** @var MeilisearchClient $client */ + $client = app(MeilisearchClient::class); + $health = $client->health(); + + if (($health['status'] ?? '') !== 'available') { + $this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health)); + return; + } + + $version = $client->version()['pkgVersion'] ?? 'unknown'; + $indexName = (new Artwork())->searchableAs(); + $index = $client->index($indexName); + $stats = $index->stats(); + $docCount = (int) ($stats['numberOfDocuments'] ?? 0); + $isIndexing = (bool) ($stats['isIndexing'] ?? false); + + // Expected: ≥ 50% of DB artworks should be indexed + $dbCount = DB::table('artworks') + ->whereNull('deleted_at') + ->where('is_public', 1) + ->where('is_approved', 1) + ->count(); + + $status = 'pass'; + $message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount})."; + + if ($isIndexing) { + $message .= ' [currently re-indexing]'; + } + + if ($docCount === 0) { + $status = 'fail'; + $message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\""; + } elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) { + $status = 'warn'; + $message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild"; + } + + // Check pending Meilisearch tasks + try { + $tasks = $client->getTasks(['statuses' => 'enqueued,processing']); + $pendingCount = $tasks->getTotal(); + if ($pendingCount > 0) { + $message .= " ({$pendingCount} tasks still pending)"; + } + } catch (Throwable) { + // non-fatal + } + + $this->result('meilisearch', $status, $message, [ + 'index' => $indexName, + 'indexed_docs' => $docCount, + 'db_eligible' => $dbCount, + 'is_indexing' => $isIndexing, + 'meili_version' => $version, + ]); + } catch (Throwable $e) { + $this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage()); + } + } + + private function checkReverb(): void + { + $host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', ''); + $port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443)); + $scheme = config('reverb.servers.reverb.options.scheme') ?? env('REVERB_SCHEME', 'https'); + + if (empty($host)) { + $this->warn_check('reverb', 'REVERB_HOST not configured — skipping.'); + return; + } + + // Reverb exposes an HTTP health endpoint at /apps/{appId} + // We do a plain TCP connect as the minimal check; a refused connection means down. + $timeout = 5; + try { + $errno = 0; + $errstr = ''; + $proto = $scheme === 'https' ? 'ssl' : 'tcp'; + $fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout); + + if ($fp === false) { + $this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]"); + return; + } + + fclose($fp); + + // Try the HTTP health probe (Reverb answers 200 on /) + $url = "{$scheme}://{$host}:{$port}/"; + $response = $this->httpGet($url, 3); + $code = $response['code'] ?? 0; + + // Reverb returns 200 or 400 on the root — both mean it's alive + if ($code >= 200 && $code < 500) { + $this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]); + } else { + $this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]); + } + } catch (Throwable $e) { + $this->failCheck('reverb', 'Check failed: ' . $e->getMessage()); + } + } + + private function checkVision(): void + { + $services = [ + 'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health', + 'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health', + ]; + + $allPassed = true; + $messages = []; + + foreach ($services as $label => $url) { + if ($url === '/health' || $url === '') { + $messages[] = "{$label}: not configured"; + continue; + } + + $response = $this->httpGet($url, 5); + $code = $response['code'] ?? 0; + + if ($code >= 200 && $code < 300) { + $messages[] = "{$label}: OK (HTTP {$code})"; + } elseif ($code === 0) { + $allPassed = false; + $messages[] = "{$label}: UNREACHABLE ({$url})"; + } else { + $allPassed = false; + $messages[] = "{$label}: HTTP {$code} ({$url})"; + } + } + + $summary = implode(' | ', $messages); + + if ($allPassed) { + $this->pass('vision', $summary); + } else { + $this->warn_check('vision', $summary); + } + } + + private function checkHorizon(): void + { + try { + // Horizon stores its status in Redis under the horizon:master-supervisor key prefix. + // A simpler cross-version check: look for any horizon-related Redis key. + $status = Cache::store('redis')->get('horizon:status'); + + if ($status === null) { + // Try reading directly from Redis + $status = Redis::get('horizon:status'); + } + + if ($status === null) { + $this->warn_check('horizon', 'No Horizon status key in Redis — Horizon may not be running or has never started.'); + return; + } + + $status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status); + + if ($status === 'running') { + $this->pass('horizon', "Horizon status: running."); + } elseif ($status === 'paused') { + $this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue"); + } else { + $this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon"); + } + } catch (Throwable $e) { + $this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage()); + } + } + + private function checkApp(): void + { + $appUrl = rtrim((string) config('app.url', ''), '/'); + + if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) { + $this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe."); + return; + } + + // Probe the app homepage + $response = $this->httpGet($appUrl . '/', 10); + $code = $response['code'] ?? 0; + + if ($code === 200) { + $ttfb = $response['ttfb'] ?? 0; + $this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]); + } elseif ($code > 0) { + $this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]); + } else { + $this->failCheck('app', "Homepage unreachable. URL: {$appUrl}"); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private function httpGet(string $url, int $timeout = 5): array + { + $start = microtime(true); + try { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0', + CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + $ttfb = (int) round((microtime(true) - $start) * 1000); + return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb]; + } catch (Throwable) { + return ['code' => 0, 'body' => '', 'ttfb' => 0]; + } + } + + private function pass(string $name, string $message, array $details = []): void + { + $this->result($name, 'pass', $message, $details); + } + + private function failCheck(string $name, string $message, array $details = []): void + { + $this->result($name, 'fail', $message, $details); + } + + private function warn_check(string $name, string $message, array $details = []): void + { + $this->result($name, 'warn', $message, $details); + } + + private function result(string $name, string $status, string $message, array $details = []): void + { + $this->results[$name] = [ + 'status' => $status, + 'message' => $message, + 'details' => $details, + ]; + } + + private function renderTable(): void + { + $this->newLine(); + $this->line(' SERVICE STATUS MESSAGE'); + $this->line(' ' . str_repeat('─', 90)); + + foreach ($this->results as $name => $r) { + [$icon, $color] = match ($r['status']) { + 'pass' => ['✅', 'green'], + 'warn' => ['⚠️ ', 'yellow'], + default => ['❌', 'red'], + }; + + $label = str_pad(strtoupper($name), 15); + $status = str_pad(strtoupper($r['status']), 7); + $message = $r['message']; + + $this->line(" {$icon} {$label} {$status} {$message}"); + } + + $this->line(' ' . str_repeat('─', 90)); + } + + private function hasFailures(): bool + { + return $this->countByStatus('fail') > 0; + } + + private function countByStatus(string $status): int + { + return count(array_filter($this->results, fn ($r) => $r['status'] === $status)); + } +} diff --git a/app/Console/Commands/ImportLegacyArtworks.php b/app/Console/Commands/ImportLegacyArtworks.php index 56177bff..2b7c9e7f 100644 --- a/app/Console/Commands/ImportLegacyArtworks.php +++ b/app/Console/Commands/ImportLegacyArtworks.php @@ -13,12 +13,14 @@ use Throwable; * * Usage: * php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run + * php artisan skinbase:import-legacy-artworks --artwork-id=69527 */ class ImportLegacyArtworks extends Command { protected $signature = 'skinbase:import-legacy-artworks {--chunk=500 : chunk size for processing} {--limit= : maximum number of legacy rows to import} + {--artwork-id= : import only one legacy wallz row by id} {--dry-run : do not persist any changes} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=wallz : legacy artworks table name} @@ -73,15 +75,28 @@ class ImportLegacyArtworks extends Command { $chunk = (int) $this->option('chunk'); $limit = $this->option('limit') ? (int) $this->option('limit') : null; + $artworkId = $this->option('artwork-id') ? (int) $this->option('artwork-id') : null; $dryRun = (bool) $this->option('dry-run'); $legacyConn = $this->option('legacy-connection'); $legacyTable = $this->option('legacy-table'); $connectedTable = $this->option('connected-table'); + if ($artworkId !== null && $artworkId <= 0) { + $this->error('The --artwork-id option must be a positive integer.'); + + return self::FAILURE; + } + $this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})"); $query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id'); + if ($artworkId !== null) { + $this->info("Scoping import to legacy artwork id={$artworkId}"); + $query->where('id', $artworkId); + $limit = 1; + } + $processed = 0; $query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) { @@ -277,8 +292,14 @@ class ImportLegacyArtworks extends Command return null; }, 'id'); + if ($artworkId !== null && $processed === 0) { + $this->warn("Legacy artwork id={$artworkId} was not found in {$legacyConn}.{$legacyTable}."); + + return self::FAILURE; + } + $this->info('Import complete. Processed: ' . $processed); - return 0; + return self::SUCCESS; } } diff --git a/app/Console/Commands/RecalculateHeatCommand.php b/app/Console/Commands/RecalculateHeatCommand.php index 5e34342f..e7ba9382 100644 --- a/app/Console/Commands/RecalculateHeatCommand.php +++ b/app/Console/Commands/RecalculateHeatCommand.php @@ -12,21 +12,22 @@ use Illuminate\Support\Facades\Log; * Runs every 10–15 minutes via scheduler. * * Formula: - * raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6 - * + comments_delta*8 + shares_delta*12 + * raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6 + * + comments_delta*8 + shares_delta*12) / window_hours) * * age_factor = 1 / (1 + hours_since_upload / 24) * * heat_score = raw_heat * age_factor * * Usage: php artisan nova:recalculate-heat - * php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run + * php artisan nova:recalculate-heat --days=60 --chunk=1000 --lookback-hours=24 --dry-run */ class RecalculateHeatCommand extends Command { protected $signature = 'nova:recalculate-heat {--days=60 : Only process artworks created within this many days} {--chunk=1000 : Chunk size for DB queries} + {--lookback-hours=24 : Smooth heat deltas over this many trailing hours} {--dry-run : Compute scores without writing to DB}'; protected $description = 'Recalculate heat/momentum scores for the Rising engine'; @@ -44,31 +45,34 @@ class RecalculateHeatCommand extends Command { $days = (int) $this->option('days'); $chunk = (int) $this->option('chunk'); + $lookbackHours = max(1, (int) $this->option('lookback-hours')); $dryRun = (bool) $this->option('dry-run'); $now = now(); $currentHour = $now->copy()->startOfHour(); $prevHour = $currentHour->copy()->subHour(); + $lookbackStart = $currentHour->copy()->subHours($lookbackHours); - $this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : '')); + $this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : '')); $updatedCount = 0; $skippedCount = 0; - // Process in chunks using artwork IDs that have at least one snapshot in the two hours + // Process in chunks using artwork IDs that have at least one snapshot in the smoothing window $artworkIds = DB::table('artwork_metric_snapshots_hourly') - ->whereIn('bucket_hour', [$currentHour, $prevHour]) + ->whereBetween('bucket_hour', [$lookbackStart, $currentHour]) ->distinct() ->pluck('artwork_id'); if ($artworkIds->isEmpty()) { - $this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.'); + $this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.'); return self::SUCCESS; } - // Load all snapshots for the two hours in bulk + // Load all snapshots for the lookback window in bulk $snapshots = DB::table('artwork_metric_snapshots_hourly') - ->whereIn('bucket_hour', [$currentHour, $prevHour]) + ->whereBetween('bucket_hour', [$lookbackStart, $currentHour]) ->whereIn('artwork_id', $artworkIds) + ->orderBy('bucket_hour') ->get() ->groupBy('artwork_id'); @@ -101,27 +105,57 @@ class RecalculateHeatCommand extends Command } $currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString()); - $prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString()); + if (! $currentSnapshot) { + $currentSnapshot = $artworkSnapshots->last(); + } - // If we only have one snapshot, use it as current with zero deltas - if (!$currentSnapshot && !$prevSnapshot) { + $prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString()); + $baselineSnapshot = $artworkSnapshots + ->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? '')) + ->first(); + + if (! $currentSnapshot) { $skippedCount++; continue; } - // Calculate deltas - $viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0)); - $downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0)); - $favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0)); - $commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0)); - $sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0)); + // One-hour counters remain explicit fields for dashboards and debugging. + $viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0)); + $downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0)); + $favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0)); + $commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0)); + $sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0)); + + // Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising. + // A single snapshot without an earlier baseline should not count as new momentum. + if ($baselineSnapshot) { + $viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0)); + $downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0)); + $favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0)); + $commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0)); + $sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0)); + + $windowHours = max( + 1.0, + abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour))) + ); + } else { + $viewsDelta = 0; + $downloadsDelta = 0; + $favouritesDelta = 0; + $commentsDelta = 0; + $sharesDelta = 0; + $windowHours = 1.0; + } // Raw heat - $rawHeat = ($viewsDelta * self::WEIGHTS['views']) - + ($downloadsDelta * self::WEIGHTS['downloads']) - + ($favouritesDelta * self::WEIGHTS['favourites']) - + ($commentsDelta * self::WEIGHTS['comments']) - + ($sharesDelta * self::WEIGHTS['shares']); + $rawHeat = ( + ($viewsDelta * self::WEIGHTS['views']) + + ($downloadsDelta * self::WEIGHTS['downloads']) + + ($favouritesDelta * self::WEIGHTS['favourites']) + + ($commentsDelta * self::WEIGHTS['comments']) + + ($sharesDelta * self::WEIGHTS['shares']) + ) / $windowHours; // Age factor: favors newer works $hoursSinceUpload = abs($now->floatDiffInHours($createdAt)); @@ -134,11 +168,11 @@ class RecalculateHeatCommand extends Command 'artwork_id' => $artworkId, 'heat_score' => round($heatScore, 4), 'heat_score_updated_at' => $now, - 'views_1h' => $viewsDelta, - 'downloads_1h' => $downloadsDelta, - 'favourites_1h' => $favouritesDelta, - 'comments_1h' => $commentsDelta, - 'shares_1h' => $sharesDelta, + 'views_1h' => $viewsDelta1h, + 'downloads_1h' => $downloadsDelta1h, + 'favourites_1h' => $favouritesDelta1h, + 'comments_1h' => $commentsDelta1h, + 'shares_1h' => $sharesDelta1h, ]; $updatedCount++; diff --git a/app/Console/Commands/RepairLegacyWallzUsersCommand.php b/app/Console/Commands/RepairLegacyWallzUsersCommand.php index 06ace14d..fb25328c 100644 --- a/app/Console/Commands/RepairLegacyWallzUsersCommand.php +++ b/app/Console/Commands/RepairLegacyWallzUsersCommand.php @@ -13,7 +13,7 @@ use Carbon\Carbon; class RepairLegacyWallzUsersCommand extends Command { - protected $signature = 'skinbase:repair-legacy-wallz-users + protected $signature = 'legacySB:repair-legacy-wallz-users {--chunk=500 : Number of legacy wallz rows to scan per batch} {--legacy-connection=legacy : Legacy database connection name} {--legacy-table=wallz : Legacy table to update} diff --git a/app/Http/Controllers/Dashboard/DashboardGalleryController.php b/app/Http/Controllers/Dashboard/DashboardGalleryController.php index ead9c6e4..8d209192 100644 --- a/app/Http/Controllers/Dashboard/DashboardGalleryController.php +++ b/app/Http/Controllers/Dashboard/DashboardGalleryController.php @@ -64,6 +64,18 @@ class DashboardGalleryController extends Controller { $primary = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); + $group = $artwork->group; + $isGroupPublisher = $group !== null; + $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); + $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); + $avatarUrl = $isGroupPublisher + ? $group->avatarUrl() + : AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) [ 'id' => $artwork->id, @@ -74,13 +86,18 @@ class DashboardGalleryController extends Controller 'category_slug' => $primary?->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $artwork->user?->name ?? 'Skinbase', - 'username' => $artwork->user?->username ?? '', - 'avatar_url' => AvatarUrl::forUser( - (int) ($artwork->user_id ?? 0), - $artwork->user?->profile?->avatar_hash ?? null, - 64 - ), + 'uname' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + 'published_as_type' => $isGroupPublisher ? 'group' : 'user', + 'publisher' => [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'name' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + ], 'published_at' => $artwork->published_at, 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, diff --git a/app/Http/Controllers/RSS/DiscoverFeedController.php b/app/Http/Controllers/RSS/DiscoverFeedController.php index 73394ff8..1b798ae9 100644 --- a/app/Http/Controllers/RSS/DiscoverFeedController.php +++ b/app/Http/Controllers/RSS/DiscoverFeedController.php @@ -6,9 +6,12 @@ namespace App\Http\Controllers\RSS; use App\Http\Controllers\Controller; use App\Models\Artwork; +use App\Services\EarlyGrowth\AdaptiveTimeWindow; use App\Services\RSS\RSSFeedBuilder; use Illuminate\Http\Response; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; /** * DiscoverFeedController @@ -22,7 +25,10 @@ use Illuminate\Support\Facades\Cache; */ final class DiscoverFeedController extends Controller { - public function __construct(private readonly RSSFeedBuilder $builder) {} + public function __construct( + private readonly RSSFeedBuilder $builder, + private readonly AdaptiveTimeWindow $timeWindow, + ) {} /** /rss/discover → redirect to fresh */ public function index(): Response @@ -77,15 +83,19 @@ final class DiscoverFeedController extends Controller public function rising(): Response { $feedUrl = url('/rss/discover/rising'); - $artworks = Cache::remember('rss:discover:rising', 600, fn () => - Artwork::public()->published() - ->with(['user:id,username', 'categories:id,name,slug,content_type_id']) - ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') - ->orderByDesc('artwork_stats.heat_score') - ->orderByDesc('artworks.published_at') - ->select('artworks.*') - ->limit(RSSFeedBuilder::FEED_LIMIT) - ->get() + $windowDays = $this->timeWindow->getTrendingWindowDays(30); + $artworks = Cache::remember( + "rss:discover:rising.{$windowDays}d", + 600, + function () use ($windowDays) { + $artworks = $this->risingArtworks($windowDays); + + if ($this->collectionHasNoRisingMomentum($artworks)) { + return $this->risingLowSignalArtworks($windowDays); + } + + return $artworks; + } ); return $this->builder->buildFromArtworks( @@ -95,4 +105,76 @@ final class DiscoverFeedController extends Controller $artworks, ); } + + private function risingArtworks(int $windowDays): Collection + { + $cutoff = now()->subDays($windowDays)->startOfDay(); + + return Artwork::public() + ->published() + ->with(['user:id,username', 'categories:id,name,slug,content_type_id']) + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score') + ->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity') + ->where('artworks.published_at', '>=', $cutoff) + ->orderByDesc('artwork_stats.heat_score') + ->orderByDesc('artwork_stats.engagement_velocity') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->limit(RSSFeedBuilder::FEED_LIMIT) + ->get(); + } + + private function risingLowSignalArtworks(int $windowDays): Collection + { + $cutoff = now()->subDays($windowDays)->startOfDay(); + + return Artwork::public() + ->published() + ->with(['user:id,username', 'categories:id,name,slug,content_type_id']) + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void { + $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id'); + }) + ->select('artworks.*') + ->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score') + ->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity') + ->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h') + ->where('artworks.published_at', '>=', $cutoff) + ->orderByDesc('recent_signal_24h') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->limit(RSSFeedBuilder::FEED_LIMIT) + ->get(); + } + + private function collectionHasNoRisingMomentum(Collection $artworks): bool + { + if ($artworks->isEmpty()) { + return false; + } + + return $artworks->every(function (Artwork $artwork): bool { + return (float) ($artwork->heat_score ?? 0) <= 0 + && (float) ($artwork->engagement_velocity ?? 0) <= 0; + }); + } + + private function risingRecentActivitySubquery() + { + $since = now()->startOfHour()->subHours(24); + + return DB::table('artwork_metric_snapshots_hourly as rising_snapshots') + ->selectRaw('rising_snapshots.artwork_id') + ->selectRaw('( + COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0) + + (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3) + + (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4) + + (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5) + + (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6) + ) as recent_signal_24h') + ->where('rising_snapshots.bucket_hour', '>=', $since) + ->groupBy('rising_snapshots.artwork_id'); + } } diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index a74861c7..a706b24d 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -280,11 +280,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller { $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 - ); + $group = $artwork->group; + $isGroupPublisher = $group !== null; + $avatarUrl = $isGroupPublisher + ? $group->avatarUrl() + : \App\Support\AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); + $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) [ 'id' => $artwork->id, @@ -295,9 +302,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'category_slug' => $primaryCategory->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $artwork->user?->name ?? 'Skinbase', - 'username' => $artwork->user?->username ?? '', + 'uname' => $displayName, + 'username' => $username, 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + 'published_as_type' => $isGroupPublisher ? 'group' : 'user', + 'publisher' => [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'name' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + ], 'published_at' => $artwork->published_at, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 54427b74..1bd9e75c 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -7,7 +7,7 @@ use App\Models\Artwork; use App\Services\CommunityActivityService; use App\Services\ArtworkSearchService; use App\Services\ArtworkService; -use App\Services\EarlyGrowth\FeedBlender; +use App\Services\EarlyGrowth\AdaptiveTimeWindow; use App\Services\EarlyGrowth\GridFiller; use App\Services\Recommendations\RecommendationFeedResolver; use App\Services\UserSuggestionService; @@ -33,8 +33,8 @@ final class DiscoverController extends Controller public function __construct( private readonly ArtworkService $artworkService, private readonly ArtworkSearchService $searchService, + private readonly AdaptiveTimeWindow $timeWindow, private readonly RecommendationFeedResolver $feedResolver, - private readonly FeedBlender $feedBlender, private readonly GridFiller $gridFiller, private readonly CommunityActivityService $communityActivity, private readonly UserSuggestionService $userSuggestions, @@ -45,9 +45,18 @@ final class DiscoverController extends Controller public function trending(Request $request) { $perPage = 24; - $page = max(1, (int) $request->query('page', 1)); - $results = $this->searchService->discoverTrending($perPage); - $results = $this->gridFiller->fill($results, 0, $page); + $windowDays = $this->timeWindow->getTrendingWindowDays(30); + + try { + $results = $this->searchService->discoverTrending($perPage); + } catch (\Throwable) { + $results = $this->fallbackTrendingFromDatabase($perPage, $windowDays); + } + + if ($this->paginatorIsEmpty($results)) { + $results = $this->fallbackTrendingFromDatabase($perPage, $windowDays); + } + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ @@ -64,9 +73,22 @@ final class DiscoverController extends Controller public function rising(Request $request) { $perPage = 24; - $page = max(1, (int) $request->query('page', 1)); - $results = $this->searchService->discoverRising($perPage); - $results = $this->gridFiller->fill($results, 0, $page); + $windowDays = $this->timeWindow->getTrendingWindowDays(30); + + try { + $results = $this->searchService->discoverRising($perPage); + } catch (\Throwable) { + $results = $this->fallbackRisingFromDatabase($perPage, $windowDays); + } + + if ($this->paginatorIsEmpty($results)) { + $results = $this->fallbackRisingFromDatabase($perPage, $windowDays); + } + + if ($this->paginatorHasNoRisingMomentum($results)) { + $results = $this->fallbackRisingLowSignalFromDatabase($perPage, $windowDays); + } + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ @@ -83,11 +105,12 @@ final class DiscoverController extends Controller public function fresh(Request $request) { $perPage = 24; - $page = max(1, (int) $request->query('page', 1)); $results = $this->searchService->discoverFresh($perPage); - // EGS: blend fresh feed with curated + spotlight on page 1 - $results = $this->feedBlender->blend($results, $perPage, $page); - $results = $this->gridFiller->fill($results, 0, $page); + + if ($this->paginatorIsEmpty($results)) { + $results = $this->fallbackFreshFromDatabase($perPage); + } + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ @@ -351,6 +374,152 @@ final class DiscoverController extends Controller // ─── Helpers ───────────────────────────────────────────────────────────── + private function paginatorIsEmpty($paginator): bool + { + if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) { + return true; + } + + $items = $paginator->getCollection(); + + return ! $items || $items->isEmpty(); + } + + private function paginatorHasNoRisingMomentum($paginator): bool + { + if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) { + return true; + } + + $items = $paginator->getCollection(); + + if (! $items || $items->isEmpty()) { + return true; + } + + return $items->every(function ($item): bool { + $heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0); + $velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0); + + return $heat <= 0.0 && $velocity <= 0.0; + }); + } + + private function fallbackFreshFromDatabase(int $perPage) + { + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + 'categories.contentType:id,slug,name', + ]) + ->orderByDesc('published_at') + ->orderByDesc('id') + ->paginate($perPage) + ->withQueryString(); + } + + private function fallbackTrendingFromDatabase(int $perPage, int $windowDays) + { + $cutoff = now()->subDays($windowDays)->startOfDay(); + + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + 'categories.contentType:id,slug,name', + ]) + ->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->where('artworks.published_at', '>=', $cutoff) + ->orderByDesc('discover_stats.ranking_score') + ->orderByDesc('discover_stats.engagement_velocity') + ->orderByDesc('discover_stats.views') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->paginate($perPage) + ->withQueryString(); + } + + private function fallbackRisingFromDatabase(int $perPage, int $windowDays) + { + $cutoff = now()->subDays($windowDays)->startOfDay(); + + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + 'categories.contentType:id,slug,name', + ]) + ->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score') + ->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity') + ->where('artworks.published_at', '>=', $cutoff) + ->orderByDesc('discover_stats.heat_score') + ->orderByDesc('discover_stats.engagement_velocity') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->paginate($perPage) + ->withQueryString(); + } + + private function fallbackRisingLowSignalFromDatabase(int $perPage, int $windowDays) + { + $cutoff = now()->subDays($windowDays)->startOfDay(); + $recentActivity = $this->risingRecentActivitySubquery(); + + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + 'categories.contentType:id,slug,name', + ]) + ->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id') + ->leftJoinSub($recentActivity, 'recent_rising_activity', function ($join): void { + $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id'); + }) + ->select('artworks.*') + ->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score') + ->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity') + ->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h') + ->where('artworks.published_at', '>=', $cutoff) + ->orderByDesc('recent_signal_24h') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->paginate($perPage) + ->withQueryString(); + } + + private function risingRecentActivitySubquery() + { + $since = now()->startOfHour()->subHours(24); + + return DB::table('artwork_metric_snapshots_hourly as rising_snapshots') + ->selectRaw('rising_snapshots.artwork_id') + ->selectRaw('( + COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0) + + (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3) + + (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4) + + (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5) + + (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6) + ) as recent_signal_24h') + ->where('rising_snapshots.bucket_hour', '>=', $since) + ->groupBy('rising_snapshots.artwork_id'); + } + private function hydrateDiscoverSearchResults($paginator): void { if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) { @@ -377,6 +546,7 @@ final class DiscoverController extends Controller ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', 'categories:id,name,slug,content_type_id,parent_id,sort_order', ]) ->get() @@ -398,9 +568,12 @@ final class DiscoverController extends Controller '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 ?? '', + 'uname' => $item->author_name ?? $item->author ?? $item->uname ?? 'Skinbase', + 'username' => (($item->published_as_type ?? null) === 'group') ? '' : ($item->username ?? ''), 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64), + 'profile_url' => $item->profile_url ?? null, + 'published_as_type' => $item->published_as_type ?? null, + 'publisher' => $item->publisher ?? null, '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, @@ -413,11 +586,18 @@ final class DiscoverController extends Controller { $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 - ); + $group = $artwork->group; + $isGroupPublisher = $group !== null; + $avatarUrl = $isGroupPublisher + ? $group->avatarUrl() + : \App\Support\AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); + $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) [ 'id' => $artwork->id, @@ -429,8 +609,18 @@ final class DiscoverController extends Controller 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $artwork->user->name ?? 'Skinbase', + 'uname' => $displayName, + 'username' => $username, 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + 'published_as_type' => $isGroupPublisher ? 'group' : 'user', + 'publisher' => [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'name' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + ], 'published_at' => $artwork->published_at, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php index a80261c8..75c61c60 100644 --- a/app/Http/Controllers/Web/ExploreController.php +++ b/app/Http/Controllers/Web/ExploreController.php @@ -276,11 +276,18 @@ final class ExploreController extends Controller { $primary = $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 - ); + $group = $artwork->group; + $isGroupPublisher = $group !== null; + $avatarUrl = $isGroupPublisher + ? $group->avatarUrl() + : \App\Support\AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); + $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) [ 'id' => $artwork->id, @@ -291,9 +298,18 @@ final class ExploreController extends Controller 'category_slug' => $primary->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $artwork->user?->name ?? 'Skinbase', - 'username' => $artwork->user?->username ?? '', + 'uname' => $displayName, + 'username' => $username, 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + 'published_as_type' => $isGroupPublisher ? 'group' : 'user', + 'publisher' => [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'name' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + ], 'published_at' => $artwork->published_at, 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, diff --git a/app/Http/Controllers/Web/SimilarArtworksPageController.php b/app/Http/Controllers/Web/SimilarArtworksPageController.php index 3ed1b663..4a72496f 100644 --- a/app/Http/Controllers/Web/SimilarArtworksPageController.php +++ b/app/Http/Controllers/Web/SimilarArtworksPageController.php @@ -290,11 +290,18 @@ final class SimilarArtworksPageController extends Controller { $primary = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); - $avatarUrl = AvatarUrl::forUser( - (int) ($artwork->user_id ?? 0), - $artwork->user?->profile?->avatar_hash ?? null, - 64 - ); + $group = $artwork->group; + $isGroupPublisher = $group !== null; + $avatarUrl = $isGroupPublisher + ? $group->avatarUrl() + : AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); + $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); return (object) [ 'id' => $artwork->id, @@ -305,9 +312,18 @@ final class SimilarArtworksPageController extends Controller 'category_slug' => $primary?->slug ?? '', 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $artwork->user?->name ?? 'Skinbase', - 'username' => $artwork->user?->username ?? '', + 'uname' => $displayName, + 'username' => $username, 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + 'published_as_type' => $isGroupPublisher ? 'group' : 'user', + 'publisher' => [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'name' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, + ], 'published_at' => $artwork->published_at, 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index df00402a..20fd0f02 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -8,7 +8,6 @@ use App\Http\Controllers\Controller; use App\Models\ContentType; use App\Models\Tag; use App\Services\ArtworkSearchService; -use App\Services\EarlyGrowth\GridFiller; use App\Services\Tags\TagDiscoveryService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; @@ -18,7 +17,6 @@ final class TagController extends Controller { public function __construct( private readonly ArtworkSearchService $search, - private readonly GridFiller $gridFiller, private readonly TagDiscoveryService $tagDiscovery, ) {} @@ -45,29 +43,10 @@ final class TagController extends Controller public function show(Tag $tag, Request $request): View { - $sort = $request->query('sort', 'popular'); // popular | latest | downloads + $sort = $request->query('sort', 'popular'); // popular | likes | latest | downloads $perPage = min((int) $request->query('per_page', 24), 100); - // Convert sort param to Meili sort expression - $sortMap = [ - 'popular' => 'views:desc', - 'likes' => 'likes:desc', - 'latest' => 'created_at:desc', - 'downloads' => 'downloads:desc', - ]; - $meiliSort = $sortMap[$sort] ?? 'views:desc'; - - $artworks = \App\Models\Artwork::search('') - ->options([ - 'filter' => 'is_public = true AND is_approved = true AND tags = "' . addslashes($tag->slug) . '"', - 'sort' => [$meiliSort], - ]) - ->paginate($perPage) - ->appends(['sort' => $sort]); - - // EGS: ensure tag pages never show a half-empty grid on page 1 - $page = max(1, (int) $request->query('page', 1)); - $artworks = $this->gridFiller->fill($artworks, 0, $page); + $artworks = $this->search->byTag($tag->slug, $perPage, $sort); // Eager-load relations used by the gallery presenter and thumbnails. $artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories'])); diff --git a/app/Http/Requests/Uploads/UploadChunkRequest.php b/app/Http/Requests/Uploads/UploadChunkRequest.php index 4da808fd..e3d2da0b 100644 --- a/app/Http/Requests/Uploads/UploadChunkRequest.php +++ b/app/Http/Requests/Uploads/UploadChunkRequest.php @@ -7,10 +7,24 @@ namespace App\Http\Requests\Uploads; use App\Repositories\Uploads\UploadSessionRepository; use App\Services\Uploads\UploadTokenService; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; final class UploadChunkRequest extends FormRequest { + protected function prepareForValidation(): void + { + $uploadError = $this->detectChunkUploadError(); + + if ($uploadError !== null && $uploadError !== UPLOAD_ERR_OK) { + $this->logChunkUploadFailure($uploadError); + + throw ValidationException::withMessages([ + 'chunk' => [$this->messageForUploadError($uploadError)], + ]); + } + } + public function authorize(): bool { $user = $this->user(); @@ -79,6 +93,63 @@ final class UploadChunkRequest extends FormRequest throw new NotFoundHttpException(); } + private function detectChunkUploadError(): ?int + { + $uploadedFile = $this->file('chunk'); + if ($uploadedFile !== null) { + return (int) $uploadedFile->getError(); + } + + $rawError = data_get($_FILES, 'chunk.error'); + if ($rawError === null || $rawError === '') { + return null; + } + + return (int) $rawError; + } + + private function messageForUploadError(int $error): string + { + return match ($error) { + UPLOAD_ERR_INI_SIZE => 'The upload chunk exceeded PHP upload_max_filesize. Lower UPLOAD_CHUNK_MAX_BYTES or raise upload_max_filesize/post_max_size.', + UPLOAD_ERR_FORM_SIZE => 'The upload chunk exceeded the allowed form upload size.', + UPLOAD_ERR_PARTIAL => 'The upload chunk was only partially received. Check Nginx/PHP-FPM request handling and network stability.', + UPLOAD_ERR_NO_FILE => 'No upload chunk file was received by PHP.', + UPLOAD_ERR_NO_TMP_DIR => 'PHP upload_tmp_dir is missing or unavailable. Check the configured temporary upload directory on the server.', + UPLOAD_ERR_CANT_WRITE => 'PHP could not write the upload chunk to the temporary directory. Check upload_tmp_dir permissions and free disk space.', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload chunk before Laravel could process it.', + default => 'The upload chunk failed before Laravel could read it. Check PHP temporary upload storage and request size limits.', + }; + } + + private function logChunkUploadFailure(int $error): void + { + $uploadTmpDir = (string) (ini_get('upload_tmp_dir') ?: sys_get_temp_dir() ?: ''); + $tmpExists = $uploadTmpDir !== '' ? is_dir($uploadTmpDir) : false; + $tmpWritable = $tmpExists ? is_writable($uploadTmpDir) : false; + + logger()->warning('Upload chunk failed before validation completed', [ + 'session_id' => (string) $this->input('session_id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + 'upload_error' => $error, + 'upload_error_message' => $this->messageForUploadError($error), + 'content_length' => $this->server('CONTENT_LENGTH'), + 'post_max_size' => ini_get('post_max_size'), + 'upload_max_filesize' => ini_get('upload_max_filesize'), + 'upload_tmp_dir' => $uploadTmpDir, + 'tmp_exists' => $tmpExists, + 'tmp_writable' => $tmpWritable, + 'raw_files' => isset($_FILES['chunk']) ? [ + 'name' => $_FILES['chunk']['name'] ?? null, + 'type' => $_FILES['chunk']['type'] ?? null, + 'size' => $_FILES['chunk']['size'] ?? null, + 'tmp_name' => $_FILES['chunk']['tmp_name'] ?? null, + 'error' => $_FILES['chunk']['error'] ?? null, + ] : null, + ]); + } + private function logUnauthorized(string $reason): void { logger()->warning('Upload chunk unauthorized access', [ diff --git a/app/Http/Resources/ArtworkListResource.php b/app/Http/Resources/ArtworkListResource.php index a88aac29..1aee5026 100644 --- a/app/Http/Resources/ArtworkListResource.php +++ b/app/Http/Resources/ArtworkListResource.php @@ -61,6 +61,22 @@ class ArtworkListResource extends JsonResource $decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $group = $this->relationLoaded('group') ? $this->group : null; + $user = $this->relationLoaded('user') ? $this->user : null; + $isGroupPublisher = $group !== null; + $publisher = ($group || $user) + ? [ + 'type' => $isGroupPublisher ? 'group' : 'user', + 'id' => (int) ($isGroupPublisher ? $group?->id : $user?->id), + 'name' => $decode($isGroupPublisher ? $group?->name : $user?->name), + 'username' => $isGroupPublisher ? '' : (string) ($user?->username ?? ''), + 'avatar_url' => $isGroupPublisher ? $group?->avatarUrl() : $user?->profile?->avatar_url, + 'profile_url' => $isGroupPublisher + ? $group?->publicUrl() + : (! empty($user?->username) ? '/@' . $user->username : null), + ] + : null; + return [ 'id' => $artId, 'slug' => $slugVal, @@ -71,12 +87,12 @@ class ArtworkListResource extends JsonResource 'height' => $get('height'), ], 'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')), - 'author' => $this->whenLoaded('user', function () use ($decode) { - return [ - 'name' => $decode($this->user->name ?? null), - 'avatar_url' => $this->user?->profile?->avatar_url, - ]; - }), + 'author' => $publisher, + 'publisher' => $publisher, + 'author_name' => $publisher['name'] ?? '', + 'avatar_url' => $publisher['avatar_url'] ?? null, + 'profile_url' => $publisher['profile_url'] ?? null, + 'published_as_type' => $publisher['type'] ?? null, 'category' => $primaryCategory ? [ 'slug' => $primaryCategory->slug ?? null, 'name' => $decode($primaryCategory->name ?? null), diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index f090dc28..91033f30 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -329,6 +329,7 @@ class Artwork extends Model $stat = $this->stats; $awardStat = $this->awardStat; + $publishedSortAt = $this->published_at ?? $this->created_at; // Orientation derived from pixel dimensions $orientation = 'square'; @@ -380,7 +381,8 @@ class Artwork extends Model 'downloads' => (int) ($stat?->downloads ?? 0), 'likes' => (int) ($stat?->favorites ?? 0), 'views' => (int) ($stat?->views ?? 0), - 'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '', + 'created_at' => $publishedSortAt?->toDateString() ?? '', + 'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0, 'is_public' => (bool) $this->is_public, 'is_approved' => (bool) $this->is_approved, // ── Trending / discovery fields ──────────────────────────────────── diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f3e182c6..9e35e1c6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -255,6 +255,10 @@ class AppServiceProvider extends ServiceProvider return $this->buildUploadLimits($request, 'init'); }); + RateLimiter::for('uploads-chunk', function (Request $request): array { + return $this->buildUploadLimits($request, 'chunk'); + }); + RateLimiter::for('uploads-finish', function (Request $request): array { return $this->buildUploadLimits($request, 'finish'); }); diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index 0b995502..b8730b14 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -9,6 +9,7 @@ use App\Models\Tag; use App\Services\EarlyGrowth\AdaptiveTimeWindow; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; /** * High-level search API powered by Meilisearch via Laravel Scout. @@ -19,6 +20,7 @@ final class ArtworkSearchService { private const BASE_FILTER = 'is_public = true AND is_approved = true'; private const CACHE_TTL = 300; // 5 minutes + private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads']; public function __construct( private readonly AdaptiveTimeWindow $timeWindow, @@ -82,22 +84,46 @@ final class ArtworkSearchService /** * Load artworks for a tag page, sorted by views + likes descending. */ - public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator + public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator { $tag = Tag::where('slug', $slug)->first(); if (! $tag) { return $this->emptyPaginator($perPage); } - $cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1); + $sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular'; + $cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1); - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) { - return Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"', - 'sort' => ['views:desc', 'likes:desc'], - ]) - ->paginate($perPage); + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) { + $query = Artwork::query() + ->public() + ->published() + ->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id)) + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->with(['user.profile', 'categories.contentType']); + + match ($sort) { + 'likes' => $query + ->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC') + ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') + ->orderByDesc('artworks.published_at'), + 'latest' => $query + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id'), + 'downloads' => $query + ->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC') + ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') + ->orderByDesc('artworks.published_at'), + default => $query + ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') + ->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC') + ->orderByDesc('artworks.published_at'), + }; + + return $query + ->paginate($perPage) + ->withQueryString(); }); } @@ -125,12 +151,12 @@ final class ArtworkSearchService * Used by categoryPageSort() and contentTypePageSort(). */ private const CATEGORY_SORT_FIELDS = [ - 'trending' => ['trending_score_24h:desc', 'created_at:desc'], - 'fresh' => ['created_at:desc'], + 'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'], + 'fresh' => ['published_at_ts: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'], + 'oldest' => ['published_at_ts:asc'], ]; /** Cache TTL (seconds) per sort alias for category pages. */ @@ -237,7 +263,7 @@ final class ArtworkSearchService } /** - * Most recent artworks by created_at. + * Most recent artworks by publish timestamp. */ public function recent(int $perPage = 24): LengthAwarePaginator { @@ -245,7 +271,7 @@ final class ArtworkSearchService return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, - 'sort' => ['created_at:desc'], + 'sort' => ['published_at_ts:desc'], ]) ->paginate($perPage); }); @@ -294,7 +320,7 @@ final class ArtworkSearchService return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', - 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], + 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'], ]) ->paginate($perPage); }); @@ -310,7 +336,7 @@ final class ArtworkSearchService return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, - 'sort' => ['created_at:desc'], + 'sort' => ['published_at_ts:desc'], ]) ->paginate($perPage); }); @@ -378,7 +404,7 @@ final class ArtworkSearchService } /** - * Fresh artworks in given categories, sorted by created_at desc. + * Fresh artworks in given categories, sorted by publish timestamp desc. * Used for personalized "Fresh in your favourite categories" section. * * @param string[] $categorySlugs @@ -400,7 +426,7 @@ final class ArtworkSearchService return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', - 'sort' => ['created_at:desc'], + 'sort' => ['published_at_ts:desc'], ]) ->paginate($limit); }); diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index 21232e98..c7afa5fa 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -23,6 +23,24 @@ class ArtworkService { protected int $cacheTtl = 3600; // seconds + /** + * Lightweight relations needed to render browse/list cards. + * + * @return array + */ + private function browseRelations(): array + { + return [ + 'user:id,name,username', + 'user.profile:user_id,avatar_url', + 'group:id,name,slug,avatar_path', + 'categories' => function ($q) { + $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']); + }, + ]; + } + /** * Shared browse query used by /browse, content-type pages, and category pages. */ @@ -30,13 +48,7 @@ class ArtworkService { $query = Artwork::public() ->published() - ->with([ - 'user:id,name', - 'categories' => function ($q) { - $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']); - }, - ]); + ->with($this->browseRelations()); $normalizedSort = strtolower(trim($sort)); if ($normalizedSort === 'oldest') { @@ -110,6 +122,7 @@ class ArtworkService public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator { $query = Artwork::public()->published() + ->with($this->browseRelations()) ->whereHas('categories', function ($q) use ($category) { $q->where('categories.id', $category->id); }) diff --git a/app/Services/EarlyGrowth/AdaptiveTimeWindow.php b/app/Services/EarlyGrowth/AdaptiveTimeWindow.php index 829c02fb..3b26760e 100644 --- a/app/Services/EarlyGrowth/AdaptiveTimeWindow.php +++ b/app/Services/EarlyGrowth/AdaptiveTimeWindow.php @@ -63,7 +63,7 @@ final class AdaptiveTimeWindow { $ttl = (int) config('early_growth.cache_ttl.time_window', 600); - return Cache::remember('egs.uploads_per_day', $ttl, function (): float { + return (float) Cache::remember('egs.uploads_per_day', $ttl, function (): float { $count = Artwork::query() ->where('is_public', true) ->where('is_approved', true) @@ -72,7 +72,7 @@ final class AdaptiveTimeWindow ->where('published_at', '>=', now()->subDays(7)) ->count(); - return round($count / 7, 2); + return (float) round($count / 7, 2); }); } } diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index 57fc923e..519f4fe4 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -244,6 +244,15 @@ final class HomepageService } public function getHomepageGroups(?\App\Models\User $viewer = null): array + { + if (! $viewer) { + return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups()); + } + + return $this->buildHomepageGroups($viewer); + } + + private function buildHomepageGroups(?\App\Models\User $viewer = null): array { $featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4); $spotlight = $featured[0] ?? null; @@ -314,6 +323,10 @@ final class HomepageService return $this->getRisingFromDb($limit); } + if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) { + return $this->getRisingLowSignalFromDb($limit); + } + return $items ->map(fn ($a) => $this->serializeArtwork($a)) ->values() @@ -348,6 +361,26 @@ final class HomepageService ->all(); } + private function getRisingLowSignalFromDb(int $limit): array + { + return Artwork::public() + ->published() + ->with(self::ARTWORK_SERIALIZATION_RELATIONS) + ->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void { + $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id'); + }) + ->select('artworks.*') + ->where('artworks.published_at', '>=', now()->subDays(30)) + ->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC') + ->orderByDesc('artworks.published_at') + ->orderByDesc('artworks.id') + ->limit($limit) + ->get() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } + /** * Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`. * @@ -466,26 +499,38 @@ final class HomepageService try { $since = now()->subWeek(); - $rows = DB::table('artworks') - ->join('users as u', 'u.id', '=', 'artworks.user_id') + $weeklyUploads = Artwork::query() + ->selectRaw('user_id, COUNT(*) as weekly_uploads') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->where('published_at', '>=', $since) + ->groupBy('user_id'); + + $rows = DB::table('users as u') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') - ->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id') - ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void { + $join->on('weekly_uploads.user_id', '=', 'u.id'); + }) ->select( 'u.id', 'u.name', 'u.username', 'up.avatar_hash', - DB::raw('COUNT(DISTINCT artworks.id) as upload_count'), - DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'), - DB::raw('COALESCE(SUM(s.views), 0) as total_views'), - DB::raw('COUNT(DISTINCT aw.id) as total_awards') + DB::raw('COALESCE(us.uploads_count, 0) as upload_count'), + DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'), + DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'), + DB::raw('COALESCE(us.awards_received_count, 0) as total_awards') ) - ->where('artworks.is_public', true) - ->where('artworks.is_approved', true) - ->whereNull('artworks.deleted_at') - ->whereNotNull('artworks.published_at') - ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash') + ->whereNull('u.deleted_at') + ->where('u.is_active', true) + ->where(function ($query): void { + $query->where('us.uploads_count', '>', 0) + ->orWhere('weekly_uploads.weekly_uploads', '>', 0); + }) ->orderByDesc('weekly_uploads') ->orderByDesc('total_awards') ->orderByDesc('total_views') @@ -494,18 +539,23 @@ final class HomepageService $userIds = $rows->pluck('id')->all(); - // Pick one random artwork thumbnail per creator for the card background. - $thumbsByUser = Artwork::public() + $latestArtworkIds = Artwork::public() ->published() ->whereIn('user_id', $userIds) ->whereNotNull('hash') ->whereNotNull('thumb_ext') - ->inRandomOrder() + ->selectRaw('MAX(id) as id') + ->groupBy('user_id') + ->pluck('id') + ->all(); + + $thumbsByUser = Artwork::query() + ->whereIn('id', $latestArtworkIds) ->get(['id', 'user_id', 'hash', 'thumb_ext']) - ->groupBy('user_id'); + ->keyBy('user_id'); return $rows->map(function ($u) use ($thumbsByUser) { - $artworkForBg = $thumbsByUser->get($u->id)?->first(); + $artworkForBg = $thumbsByUser->get($u->id); $bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null; return [ @@ -792,6 +842,37 @@ final class HomepageService return $artworks; } + private function collectionHasNoRisingMomentum(Collection $items): bool + { + if ($items->isEmpty()) { + return true; + } + + return $items->every(function ($item): bool { + $heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0); + $velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0); + + return $heat <= 0.0 && $velocity <= 0.0; + }); + } + + private function risingRecentActivitySubquery() + { + $since = now()->startOfHour()->subHours(24); + + return DB::table('artwork_metric_snapshots_hourly as rising_snapshots') + ->selectRaw('rising_snapshots.artwork_id') + ->selectRaw('( + COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0) + + (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3) + + (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4) + + (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5) + + (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6) + ) as recent_signal_24h') + ->where('rising_snapshots.bucket_hour', '>=', $since) + ->groupBy('rising_snapshots.artwork_id'); + } + private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array { $thumbMd = $artwork->thumbUrl('md'); diff --git a/app/Services/Studio/CreatorStudioContentService.php b/app/Services/Studio/CreatorStudioContentService.php index d16313e9..fa595629 100644 --- a/app/Services/Studio/CreatorStudioContentService.php +++ b/app/Services/Studio/CreatorStudioContentService.php @@ -407,6 +407,7 @@ final class CreatorStudioContentService { $now = Carbon::now(); $updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String())); + $status = (string) ($item['status'] ?? ''); $isDraft = ($item['status'] ?? null) === 'draft'; $missing = []; $score = 0; @@ -441,6 +442,16 @@ final class CreatorStudioContentService default => 'Needs more work', }; + $readiness = $status === 'published' + ? null + : [ + 'score' => $score, + 'max' => 4, + 'label' => $label, + 'can_publish' => $score >= 3, + 'missing' => $missing, + ]; + $workflowActions = match ((string) ($item['module'] ?? '')) { 'artworks' => [ ['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'], @@ -466,13 +477,7 @@ final class CreatorStudioContentService 'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)), 'last_touched_days' => max(0, $updatedAt->diffInDays($now)), 'resume_label' => $isDraft ? 'Resume draft' : 'Open item', - 'readiness' => [ - 'score' => $score, - 'max' => 4, - 'label' => $label, - 'can_publish' => $score >= 3, - 'missing' => $missing, - ], + 'readiness' => $readiness, 'cross_module_actions' => $workflowActions, ]; diff --git a/app/Services/Studio/Providers/ArtworkStudioProvider.php b/app/Services/Studio/Providers/ArtworkStudioProvider.php index 8cf10a77..e1fb8717 100644 --- a/app/Services/Studio/Providers/ArtworkStudioProvider.php +++ b/app/Services/Studio/Providers/ArtworkStudioProvider.php @@ -50,9 +50,10 @@ final class ArtworkStudioProvider implements CreatorStudioProvider $draftCount = (clone $baseQuery) ->whereNull('deleted_at') ->where(function (Builder $query): void { - $query->where('is_public', false) - ->orWhere('artwork_status', 'draft'); + $query->whereNull('artwork_status') + ->orWhere('artwork_status', '!=', 'scheduled'); }) + ->where('is_public', false) ->count(); $publishedCount = (clone $baseQuery) @@ -92,16 +93,29 @@ final class ArtworkStudioProvider implements CreatorStudioProvider $query = Artwork::query() ->withTrashed() ->where('user_id', $user->id) - ->with(['stats', 'categories', 'tags']) + ->with([ + 'stats', + 'categories', + 'tags', + 'features' => function ($query): void { + $query->where('is_active', true) + ->whereNull('deleted_at') + ->where(function (Builder $builder): void { + $builder->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + }, + ]) ->orderByDesc('updated_at') ->limit($limit); if ($bucket === 'drafts') { $query->whereNull('deleted_at') ->where(function (Builder $builder): void { - $builder->where('is_public', false) - ->orWhere('artwork_status', 'draft'); - }); + $builder->whereNull('artwork_status') + ->orWhere('artwork_status', '!=', 'scheduled'); + }) + ->where('is_public', false); } elseif ($bucket === 'scheduled') { $query->whereNull('deleted_at') ->where('artwork_status', 'scheduled'); @@ -199,7 +213,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider 'published_at' => $artwork->published_at?->toIso8601String(), 'scheduled_at' => $artwork->publish_at?->toIso8601String(), 'schedule_timezone' => $artwork->artwork_timezone, - 'featured' => false, + 'featured' => $artwork->features->isNotEmpty(), 'metrics' => [ 'views' => (int) ($stats?->views ?? 0), 'appreciation' => (int) ($stats?->favorites ?? 0), diff --git a/app/Services/Studio/StudioBulkActionService.php b/app/Services/Studio/StudioBulkActionService.php index 7a3ec619..9bf1f293 100644 --- a/app/Services/Studio/StudioBulkActionService.php +++ b/app/Services/Studio/StudioBulkActionService.php @@ -31,6 +31,8 @@ final class StudioBulkActionService $query = Artwork::where('user_id', $userId); if ($action === 'unarchive') { $query->onlyTrashed(); + } elseif ($action === 'delete') { + $query->withTrashed(); } $artworks = $query->whereIn('id', $artworkIds)->get(); diff --git a/config/scout.php b/config/scout.php index 23103f06..668d8dae 100644 --- a/config/scout.php +++ b/config/scout.php @@ -105,6 +105,7 @@ return [ ], 'sortableAttributes' => [ 'created_at', + 'published_at_ts', 'downloads', 'likes', 'views', diff --git a/config/uploads.php b/config/uploads.php index 31693654..f3af29a7 100644 --- a/config/uploads.php +++ b/config/uploads.php @@ -97,6 +97,10 @@ return [ 'per_user' => env('UPLOAD_RATE_INIT_USER', 10), 'per_ip' => env('UPLOAD_RATE_INIT_IP', 30), ], + 'chunk' => [ + 'per_user' => env('UPLOAD_RATE_CHUNK_USER', 180), + 'per_ip' => env('UPLOAD_RATE_CHUNK_IP', 360), + ], 'finish' => [ 'per_user' => env('UPLOAD_RATE_FINISH_USER', 6), 'per_ip' => env('UPLOAD_RATE_FINISH_IP', 12), @@ -126,6 +130,7 @@ return [ 'max_bytes' => env('UPLOAD_CHUNK_MAX_BYTES', 5242880), 'lock_seconds' => env('UPLOAD_CHUNK_LOCK_SECONDS', 10), 'lock_wait_seconds' => env('UPLOAD_CHUNK_LOCK_WAIT_SECONDS', 5), + 'request_timeout_ms' => env('UPLOAD_CHUNK_REQUEST_TIMEOUT_MS', 45000), ], 'scan' => [ diff --git a/deploy/supervisor/skinbase-queue.conf b/deploy/supervisor/skinbase-queue.conf index 5e28cc40..3210dec5 100644 --- a/deploy/supervisor/skinbase-queue.conf +++ b/deploy/supervisor/skinbase-queue.conf @@ -1,5 +1,5 @@ [program:skinbase-queue] -command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,vision,recommendations,discovery,mail,default +command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default process_name=%(program_name)s_%(process_num)02d numprocs=1 autostart=true diff --git a/deploy/systemd/skinbase-queue.service b/deploy/systemd/skinbase-queue.service index a5814ff7..8eb92827 100644 --- a/deploy/systemd/skinbase-queue.service +++ b/deploy/systemd/skinbase-queue.service @@ -8,7 +8,7 @@ Group=www-data Restart=always RestartSec=3 WorkingDirectory=/var/www/skinbase -ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,vision,recommendations,discovery,mail,default +ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default StandardOutput=syslog StandardError=syslog SyslogIdentifier=skinbase-queue diff --git a/docs/Discover/README.md b/docs/Discover/README.md new file mode 100644 index 00000000..d6100942 --- /dev/null +++ b/docs/Discover/README.md @@ -0,0 +1,93 @@ +# Discover Pages + +This folder documents how each public discovery surface is assembled today. + +It is intentionally page-oriented rather than architecture-oriented. +For the broader discovery engine, signal collection, and personalization background, also see `docs/discovery-personalization-engine.md`. + +## Pages Covered + +- `Trending` → `GET /discover/trending` +- `Rising` → `GET /discover/rising` +- `Fresh` → `GET /discover/fresh` +- `Top Rated` → `GET /discover/top-rated` +- `Most Downloaded` → `GET /discover/most-downloaded` +- `Today Downloads` → `GET /downloads/today` +- `On This Day` → `GET /discover/on-this-day` +- `For You` → `GET /discover/for-you` (auth only) + +## Shared Request Pipeline + +Most Discover pages follow this pattern: + +1. Route enters `App\Http\Controllers\Web\DiscoverController`. +2. The controller calls `App\Services\ArtworkSearchService`. +3. `ArtworkSearchService` queries the `artworks` Meilisearch index through Laravel Scout. +4. The search result usually contains only search/index fields. +5. `DiscoverController::hydrateDiscoverSearchResults()` then reloads full `Artwork` rows from MySQL with relations (`user`, `profile`, `categories`) and converts them into the view model used by Blade. +6. Some pages can still pass through additional presentation layers such as `GridFiller`, depending on the controller action. + +## Shared Visibility Rules + +All search-backed pages use the same base visibility filter in `ArtworkSearchService`: + +```text +is_public = true AND is_approved = true +``` + +That means an artwork must be: + +- public +- approved +- present in the Meilisearch index + +If the database row is correct but search is stale, the page can still miss the artwork until indexing catches up. + +## Shared Cache Behavior + +`ArtworkSearchService` uses application cache in front of Meilisearch. + +- Default TTL: 300 seconds +- `Rising`: 120 seconds +- Category/content-type sort pages use per-sort TTLs, but those are outside this folder's scope + +The page can therefore lag behind a real publish or stat change even when the underlying data is already correct. + +## Shared Supporting Jobs + +These jobs are active in the current Laravel 11 runtime scheduler (`routes/console.php`): + +- `skinbase:flush-redis-stats` every 5 minutes +- `skinbase:recalculate-trending --period=24h` every 30 minutes +- `skinbase:recalculate-trending --period=7d --skip-index` every 30 minutes +- `skinbase:reset-windowed-stats --period=24h` daily at 03:30 +- `skinbase:reset-windowed-stats --period=7d` weekly (Monday) at 03:30 +- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes +- `artworks:publish-scheduled` every minute +- `analytics:aggregate-discovery-feedback` daily at 03:25 +- `RecBuildItemPairsFromFavouritesJob` every 4 hours +- `RecComputeSimilarByTagsJob` daily at 02:00 +- `RecComputeSimilarByBehaviorJob` daily at 02:15 +- `RecComputeSimilarHybridJob` daily at 02:30 + +## Important Scheduler Caveat + +The codebase still contains some discovery-related schedules inside `app/Console/Kernel.php`, but the active Laravel 11 runtime schedule comes from `routes/console.php`. + +The Rising pipeline depends on these active runtime jobs: + +- `nova:metrics-snapshot-hourly` hourly +- `nova:recalculate-heat` every 15 minutes + +If Rising stops moving while Trending changes, check `php artisan schedule:list` first and confirm both jobs are still active. + +## File Map + +- `trending.md` +- `rising.md` +- `fresh.md` +- `top-rated.md` +- `most-downloaded.md` +- `today-downloads.md` +- `on-this-day.md` +- `for-you.md` \ No newline at end of file diff --git a/docs/Discover/for-you.md b/docs/Discover/for-you.md new file mode 100644 index 00000000..cdd80335 --- /dev/null +++ b/docs/Discover/for-you.md @@ -0,0 +1,174 @@ +# For You + +## Route + +- URL: `GET /discover/for-you` +- Auth required: yes +- Controller: `App\Http\Controllers\Web\DiscoverController::forYou()` +- Entry service: `App\Services\Recommendations\RecommendationFeedResolver` + +## High-level flow + +`For You` is not a simple search sort. +It is a recommendation pipeline with engine selection, caching, layered candidate generation, reranking, and cursor pagination. + +The controller: + +1. reads `limit` and `cursor` +2. calls `RecommendationFeedResolver::getFeed()` +3. converts feed items into the artwork card view model +4. returns HTML or JSON depending on request type + +## Engine selection + +`RecommendationFeedResolver` chooses between two implementations: + +- V2: `App\Services\Recommendations\RecommendationServiceV2` +- V1: `App\Services\Recommendations\PersonalizedFeedService` + +Selection is based on: + +- `config('discovery.v2.enabled')` +- rollout percentage bucket for the current user +- explicit `algo_version` override + +## Cache model + +Both engines use `user_recommendation_cache`. + +At request time: + +1. Load cache row for `(user_id, algo_version)` +2. Check cache version and `expires_at` +3. If missing or stale, dispatch `RegenerateUserRecommendationCacheJob` +4. If the row is empty, build fallback recommendations inline for the current request + +This means the page is usually cache-backed, but it does not hard-fail if the cache is cold. + +## V2 pipeline + +V2 is the richer layered engine. + +### Candidate layers + +The candidate pool is blended from: + +- personalized layer +- social layer +- trending layer +- exploration layer +- vector layer (only when V3/vector support is enabled and configured) + +Default target ratios from `config/discovery.php`: + +- personalized: 50% +- social: 20% +- trending: 20% +- exploration: 10% + +### Main V2 score + +For each candidate row: + +```text +score + = (base_score * weight_base) + + session_boost + + social_boost + + trending_boost + + exploration_boost + + creator_boost + + vector_boost + - negative_penalty + - repetition_penalty +``` + +Where: + +- `session_boost` comes from merged session/profile signals +- `social_boost` comes from followed creators and artworks liked by followed creators +- `trending_boost` is built from `trending_score_1h`, `trending_score_24h`, `trending_score_7d` +- `exploration_boost` rewards fresh uploads, new creators, and unseen tags +- `creator_boost` uses creator follower count plus artwork momentum metrics +- `vector_boost` comes from visual similarity when vector mode is enabled +- `negative_penalty` reflects hidden artworks and disliked tags +- `repetition_penalty` suppresses creator and tag repetition inside one result page + +### Trending contribution inside V2 + +V2 combines the artwork's stored trending columns like this: + +```text +trendingBoost + = (trending_score_1h * weight_1h) + + (trending_score_24h * weight_24h) + + (trending_score_7d * weight_7d) +``` + +Then divides by 100 before merging into the final score. + +### Negative signals + +V2 reads `user_negative_signals` and applies: + +- hidden artwork exclusion +- disliked tag penalty + +### Supporting data sources + +V2 reads from: + +- `user_recommendation_cache` +- session/profile signal builders +- `artworks` +- `artwork_stats` +- `artwork_similarities` +- `artwork_embeddings` and vector service, when enabled +- `user_followers` +- `artwork_favourites` +- `user_negative_signals` + +## V1 pipeline + +V1 is simpler and category-affinity driven. + +It reads `user_interest_profiles`, then scores candidates with a weighted blend: + +```text +score = (w1 * affinity) + + (w2 * recency) + + (w3 * popularity) + + (w4 * novelty) +``` + +Cold start falls back to a blend of popular artworks and `artwork_similarities` seeds. + +## Background jobs and schedules + +### Directly relevant + +- `RegenerateUserRecommendationCacheJob` + - dispatched on demand when cache is missing or stale + +### Support jobs for candidate quality + +- `RecBuildItemPairsFromFavouritesJob` every 4 hours +- `RecComputeSimilarByTagsJob` daily at 02:00 +- `RecComputeSimilarByBehaviorJob` daily at 02:15 +- `RecComputeSimilarHybridJob` daily at 02:30 +- `analytics:aggregate-discovery-feedback` daily at 03:25 + +These jobs do not directly render the page, but they improve the offline inputs and behavioral data used by the recommender. + +## Cache behavior + +- V1 cache TTL default: 60 minutes +- V2 cache TTL default: 15 minutes + +Cursor pagination is offset-based under the hood. + +## Notes + +- `For You` is the most configuration-sensitive page in this set. +- What a given user sees can differ by rollout bucket, `algo_version`, cache state, and whether V2/V3 features are enabled. +- If you are debugging a single user's page, inspect `RecommendationFeedResolver::inspectDecision()` first. \ No newline at end of file diff --git a/docs/Discover/fresh.md b/docs/Discover/fresh.md new file mode 100644 index 00000000..7daed0dc --- /dev/null +++ b/docs/Discover/fresh.md @@ -0,0 +1,88 @@ +# Fresh + +## Route + +- URL: `GET /discover/fresh` +- Controller: `App\Http\Controllers\Web\DiscoverController::fresh()` +- Service: `App\Services\ArtworkSearchService::discoverFresh()` + +## What the page reads + +Fresh is Meilisearch-backed, then hydrated from MySQL. + +The page does not directly query `artworks` for ranking order. +It relies on the search index being up to date. + +If the search-backed result comes back empty, the controller falls back to a direct MySQL query ordered by `published_at DESC, id DESC` so the page does not render blank while search is catching up. + +## Search query + +`discoverFresh()` uses: + +- filter: `is_public = true AND is_approved = true` +- sort: + - `published_at_ts:desc` + +`published_at_ts` is a numeric timestamp field stored in the search document specifically so same-day uploads can be ordered correctly by hour and minute. + +## Why the dedicated timestamp field exists + +Historically, the index sorted by a date-only `created_at` string, which meant all uploads on the same calendar day could collapse to the same sort value. + +The current implementation uses `published_at_ts` to preserve intra-day ordering and avoid newer uploads being buried behind older uploads from the same date. + +## Page behavior + +Fresh now uses the raw newest-first search result without any curated blending or grid filler injection. + +That means: + +- page 1 is not mixed with spotlight content +- page 1 is not padded with older trending artworks +- deeper pages follow the same ordering model as page 1 + +If older artworks appear near the top, the likely causes are stale search documents or stale cache, not intentional feed mixing. + +If the page renders completely empty even though recent public artworks exist, the DB fallback should populate it. A blank page after that points to a real data visibility problem, not just search freshness. + +## Data sources + +Ranking eligibility depends on: + +- `is_public` +- `is_approved` +- presence in Meilisearch +- `published_at_ts` in the indexed document + +Hydration reads full rows from MySQL after the search query returns IDs. + +When the fallback path is used, the page is served directly from MySQL and does not require Meilisearch for that request. + +## Relevant jobs and schedules + +Fresh does not have a dedicated score calculation job. +It depends on publication and indexing freshness. + +Relevant active schedules: + +- `artworks:publish-scheduled` every minute +- `skinbase:flush-redis-stats` every 5 minutes (not for ordering, but for displayed stats freshness) + +Index freshness depends on: + +- normal Scout indexing from artwork updates +- scheduled-publication indexing after an artwork transitions from scheduled to published +- manual/full imports after search-document schema changes + +## Cache behavior + +- Cache key: `discover.fresh.{page}` +- TTL: 300 seconds + +## Notes + +- Fresh is the page most sensitive to stale indexing because it is supposed to surface the latest publish action immediately. +- If an artwork is public in MySQL but absent from Fresh, the usual causes are: + - it has not been indexed yet + - app cache has not expired yet + - the artwork is not actually public and approved at the same time \ No newline at end of file diff --git a/docs/Discover/most-downloaded.md b/docs/Discover/most-downloaded.md new file mode 100644 index 00000000..b4aaf2bc --- /dev/null +++ b/docs/Discover/most-downloaded.md @@ -0,0 +1,59 @@ +# Most Downloaded + +## Route + +- URL: `GET /discover/most-downloaded` +- Controller: `App\Http\Controllers\Web\DiscoverController::mostDownloaded()` +- Service: `App\Services\ArtworkSearchService::discoverMostDownloaded()` + +## What the page reads + +This page is Meilisearch-ranked and then hydrated from MySQL. + +## Search query + +`discoverMostDownloaded()` uses: + +- filter: `is_public = true AND is_approved = true` +- sort: + - `downloads:desc` + - `views:desc` + +In the indexed document: + +- `downloads` is sourced from `artwork_stats.downloads` +- `views` is sourced from `artwork_stats.views` + +This is therefore an all-time leaderboard, not a rolling-window leaderboard. + +## Where the download counts come from + +Downloads are recorded in two places: + +1. `artwork_downloads` + - full event log of each tracked download +2. `artwork_stats.downloads` + - all-time aggregate counter used by this page + +The page sorts on the aggregate counter, not by counting the event log live. + +## Background jobs and schedules + +No dedicated page-specific cron exists. + +Relevant active maintenance: + +- `skinbase:flush-redis-stats` every 5 minutes + - pushes deferred Redis counters into MySQL + +Window reset commands exist too, but they maintain `downloads_24h` and `downloads_7d` rather than the all-time `downloads` column used by this page. + +## Cache behavior + +- Cache key: `discover.most-downloaded.{page}` +- TTL: 300 seconds + +## Notes + +- This page is separate from `Today Downloads`. +- If you need "what is hot today by download activity," use the dedicated `/downloads/today` page instead. \ No newline at end of file diff --git a/docs/Discover/on-this-day.md b/docs/Discover/on-this-day.md new file mode 100644 index 00000000..cbb022c5 --- /dev/null +++ b/docs/Discover/on-this-day.md @@ -0,0 +1,52 @@ +# On This Day + +## Route + +- URL: `GET /discover/on-this-day` +- Controller: `App\Http\Controllers\Web\DiscoverController::onThisDay()` + +## What the page reads + +This page is a direct MySQL query over `artworks`. +It does not use Meilisearch. + +## Query logic + +The controller selects artworks that are: + +- public +- published +- approved +- published on the same month/day as today +- from a previous year only + +It then sorts them by `published_at DESC` and paginates 24 per page. + +Equivalent logic: + +```text +MONTH(published_at) = today.month +AND DAY(published_at) = today.day +AND YEAR(published_at) < today.year +``` + +## Data sources + +- primary source: `artworks` +- supporting eager loads: `user`, `user.profile`, `categories` + +## Cache behavior + +- no explicit controller cache + +## Background jobs and schedules + +No dedicated cron drives this page. + +It only depends on correct `published_at` values and the usual public/published scopes. + +## Notes + +- This is a calendar-history page, not a popularity or momentum page. +- The current implementation simply orders by newest qualifying `published_at`, not by views, downloads, or favourites. +- There are also legacy `TodayInHistoryController` variants elsewhere in the codebase, but `/discover/on-this-day` currently uses `DiscoverController::onThisDay()`. \ No newline at end of file diff --git a/docs/Discover/rising.md b/docs/Discover/rising.md new file mode 100644 index 00000000..0192dc09 --- /dev/null +++ b/docs/Discover/rising.md @@ -0,0 +1,115 @@ +# Rising + +## Route + +- URL: `GET /discover/rising` +- Controller: `App\Http\Controllers\Web\DiscoverController::rising()` +- Service: `App\Services\ArtworkSearchService::discoverRising()` +- RSS feed: `GET /rss/discover/rising` via `App\Http\Controllers\RSS\DiscoverFeedController::rising()` + +## What the page reads + +Like most Discover surfaces, this page ranks via Meilisearch and then hydrates the result IDs from MySQL for presentation. + +If the search-backed query throws or returns no items, the controller falls back to a direct MySQL query against `artworks` + `artwork_stats`. + +If the page receives a non-empty result set but every item has zero `heat_score` and zero `engagement_velocity`, it switches to a low-signal fallback policy instead of pretending that the zero-heat order is meaningful. + +The RSS Rising feed now follows the same low-signal policy and the same adaptive lookback window, so it does not drift to a stale zero-heat ordering when recent engagement is sparse. + +Primary ranking fields: + +- `heat_score` +- `engagement_velocity` +- `published_at_ts` as the final recency tie-breaker + +## Search query + +`discoverRising()` uses: + +- filter: `is_public = true AND is_approved = true AND created_at >= cutoff` +- sort: + - `heat_score:desc` + - `engagement_velocity:desc` + - `published_at_ts:desc` + +The cutoff comes from the same adaptive time-window service used by Trending. + +## Rising formula + +`heat_score` is produced by `App\Console\Commands\RecalculateHeatCommand`. + +Current formula: + +```text +raw_heat + = ((views_delta * 1) + + (downloads_delta * 3) + + (favourites_delta * 6) + + (comments_delta * 8) + + (shares_delta * 12)) / window_hours + +age_factor + = 1 / (1 + hours_since_upload / 24) + +heat_score + = raw_heat * age_factor +``` + +The heat command smooths deltas over a trailing lookback window, rather than relying only on the last single hour. + +That matters on low-traffic periods, because a pure 1-hour delta often collapses to zero for almost every artwork. + +An artwork still needs at least two snapshots inside that window for the smoothed heat delta to count. A single snapshot without an earlier baseline does not count as momentum. + +The `views_1h`, `downloads_1h`, `favourites_1h`, `comments_1h`, and `shares_1h` columns are still stored from the previous-hour comparison for diagnostics and dashboards. + +## Data sources + +The page depends on: + +- `artwork_metric_snapshots_hourly` +- `artwork_stats.heat_score` +- `artwork_stats.engagement_velocity` +- artwork publish timestamps + +In zero-signal periods, the fallback policy also uses a 24-hour snapshot delta rollup from `artwork_metric_snapshots_hourly` and then falls back to `published_at DESC`. + +`engagement_velocity` is not part of the heat command. It comes from the ranking engine and acts as a secondary momentum signal. + +## Intended background jobs + +The intended pipeline is: + +1. `nova:metrics-snapshot-hourly` + - captures hourly totals into `artwork_metric_snapshots_hourly` +2. `nova:recalculate-heat` + - computes `heat_score` from snapshot deltas +3. Meilisearch picks up the updated score after indexing + +## Runtime schedule + +Rising depends on two active Laravel 11 runtime jobs in `routes/console.php`: + +- `nova:metrics-snapshot-hourly` +- `nova:recalculate-heat` + +If either one disappears from `php artisan schedule:list`, Rising will quickly drift toward stale or low-signal ordering. + +## Active jobs that still affect Rising + +- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes updates `engagement_velocity` +- `skinbase:flush-redis-stats` every 5 minutes keeps all-time stats fresher + +## Cache behavior + +- Cache key: `discover.rising.{windowDays}d.{page}` +- TTL: 120 seconds + +If Meilisearch sort settings are missing or the search result is empty, the controller falls back to the DB query instead of returning an empty page or a 500. + +## Notes + +- If Rising looks frozen while Trending moves, the first place to check is whether `nova:metrics-snapshot-hourly` and `nova:recalculate-heat` are actually being executed in production. +- The page no longer uses `GridFiller`, so it should not pull in unrelated older artworks when the real result set is thin. +- If all heat and velocity values are zero, Rising intentionally behaves like a low-signal discovery feed: recent activity in the last 24 hours first, then newest published artworks. \ No newline at end of file diff --git a/docs/Discover/today-downloads.md b/docs/Discover/today-downloads.md new file mode 100644 index 00000000..55587e4b --- /dev/null +++ b/docs/Discover/today-downloads.md @@ -0,0 +1,63 @@ +# Today Downloads + +## Route + +- URL: `GET /downloads/today` +- Controller: `App\Http\Controllers\User\TodayDownloadsController::index()` + +## Important scope note + +This page is not inside `/discover/*`, but it is included here because it is discovery-adjacent and was requested together with the Discover surfaces. + +## What the page reads + +This page does not use Meilisearch. +It is a direct MySQL query over the download event log. + +## Query logic + +The controller: + +1. Takes today's date +2. Reads `artwork_downloads` +3. Filters rows to `whereDate(created_at, today)` +4. Groups by `artwork_id` +5. Orders by `COUNT(*) DESC` +6. Eager-loads each artwork and related user/category data + +Effectively: + +```text +SELECT artwork_id, COUNT(*) AS num_downloads +FROM artwork_downloads +WHERE DATE(created_at) = today +GROUP BY artwork_id +ORDER BY num_downloads DESC +``` + +Only artworks that are currently public and published are allowed through `whereHas('artwork', ...)`. + +## Data sources + +- primary source: `artwork_downloads` +- supporting source: `artworks`, `users`, `user_profiles`, `categories` + +Unlike `Most Downloaded`, this page does not trust the aggregate `artwork_stats.downloads` counter for ranking. +It re-counts today's actual events. + +## Cache behavior + +- no dedicated application cache in the controller + +The page is as fresh as the underlying event log. + +## Background jobs and schedules + +No page-specific cron is needed because it reads the raw event log directly. + +The only prerequisite is that downloads are being recorded correctly by the download endpoint. + +## Notes + +- This page is often the more operationally trustworthy answer to "what is being downloaded right now?" +- Because it counts raw rows, it is less sensitive to delayed aggregate-counter flushes than `Most Downloaded`. \ No newline at end of file diff --git a/docs/Discover/top-rated.md b/docs/Discover/top-rated.md new file mode 100644 index 00000000..c175777b --- /dev/null +++ b/docs/Discover/top-rated.md @@ -0,0 +1,58 @@ +# Top Rated + +## Route + +- URL: `GET /discover/top-rated` +- Controller: `App\Http\Controllers\Web\DiscoverController::topRated()` +- Service: `App\Services\ArtworkSearchService::discoverTopRated()` + +## What the page reads + +This is a Meilisearch-ranked page with MySQL hydration after the fact. + +## Search query + +`discoverTopRated()` uses: + +- filter: `is_public = true AND is_approved = true` +- sort: + - `likes:desc` + - `views:desc` + +In the indexed document: + +- `likes` is sourced from `artwork_stats.favorites` +- `views` is sourced from `artwork_stats.views` + +So "Top Rated" really means highest favourite count, with views as a tie-breaker. + +## Data sources + +The page depends on: + +- `artwork_stats.favorites` +- `artwork_stats.views` +- Scout/Meilisearch document freshness + +It does not run a custom score formula beyond the sort order above. + +## Background jobs and schedules + +There is no dedicated top-rated cron. +The page depends on the freshness of the underlying stats. + +Relevant active maintenance: + +- `skinbase:flush-redis-stats` every 5 minutes for deferred stat deltas + +Favorites themselves are typically updated in the normal request path rather than by a dedicated scheduled command. + +## Cache behavior + +- Cache key: `discover.top-rated.{page}` +- TTL: 300 seconds + +## Notes + +- Awards are not part of this page's ranking. +- If the business meaning should be "best overall" rather than "most favourited," this page would need a different sort field. \ No newline at end of file diff --git a/docs/Discover/trending.md b/docs/Discover/trending.md new file mode 100644 index 00000000..15e1e811 --- /dev/null +++ b/docs/Discover/trending.md @@ -0,0 +1,125 @@ +# Trending + +## Route + +- URL: `GET /discover/trending` +- Controller: `App\Http\Controllers\Web\DiscoverController::trending()` +- Service: `App\Services\ArtworkSearchService::discoverTrending()` + +## What the page actually reads + +Primary ranking comes from Meilisearch, not directly from MySQL. + +The controller flow is: + +1. Query Meilisearch through `ArtworkSearchService` +2. If the search-backed query throws or returns no items, fall back to a direct MySQL query against `artworks` + `artwork_stats` +3. Hydrate the returned IDs back into full `Artwork` Eloquent models when needed +4. Render `resources/views/web/discover/index.blade.php` + +So the page is effectively: + +- ranking source: Meilisearch +- fallback ranking source: MySQL + `artwork_stats` +- display hydration source: MySQL + +## Search query + +`discoverTrending()` uses: + +- filter: `is_public = true AND is_approved = true AND created_at >= cutoff` +- sort: + - `ranking_score:desc` + - `engagement_velocity:desc` + - `views:desc` + +`created_at` here is the search index field, not necessarily the raw DB column semantics. + +## Time window + +The cutoff is not hardcoded to 30 days in all cases. + +`App\Services\EarlyGrowth\AdaptiveTimeWindow` chooses the effective look-back window: + +- 7 days when uploads/day is healthy +- 30 days in moderate activity +- 90 days in low activity + +That widening only changes which artworks are eligible for the query. +It does not rewrite timestamps. + +## Ranking formula + +The page does not sort by `trending_score_7d` anymore. +It sorts by `ranking_score`, which is produced by `App\Services\Ranking\ArtworkRankingService`. + +Current V2 ranking formula: + +```text +base_score + = (views_all * 0.2) + + (downloads_all * 1.5) + + (favourites_all * 2.5) + + (comments_count * 3.0) + + (shares_count * 4.0) + +authority_multiplier + = 1 + ((log10(1 + author_followers_count) + + (author_favourites_received / 1000)) * 0.05) + +decay_factor + = 1 / (1 + age_hours / 48) + +velocity_boost + = ((views_24h * 1.0) + + (favourites_24h * 3.0) + + (comments_24h * 4.0) + + (shares_24h * 5.0)) * 0.5 + +ranking_score + = (base_score * authority_multiplier * decay_factor) + velocity_boost +``` + +`engagement_velocity` is the raw `velocity_boost` term stored separately in `artwork_stats`. + +## Where the numbers come from + +The page depends mainly on: + +- `artwork_stats.ranking_score` +- `artwork_stats.engagement_velocity` +- `artwork_stats.views` +- `artwork_stats.downloads` +- `artwork_stats.favorites` +- `artwork_stats.comments_count` +- `artwork_stats.shares_count` +- author-level follower and favourites-received signals + +Those values are copied into the search document by `Artwork::toSearchableArray()`. + +## Active jobs and schedules + +Relevant active schedules: + +- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes +- `skinbase:flush-redis-stats` every 5 minutes + +Indirectly relevant: + +- `artworks:publish-scheduled` every minute +- Meilisearch indexing jobs dispatched after score recalculation + +## Cache behavior + +- Cache key: `discover.trending.{windowDays}d.{page}` +- TTL: 300 seconds + +That means a ranking update can be correct in Meilisearch but still hidden by app cache for up to 5 minutes. + +If Meilisearch sort settings are missing or the index returns no results, the controller falls back to a DB query instead of rendering a 500 or an empty page. + +## Notes + +- The view text still says "most-viewed artworks on Skinbase over the past 7 days," but the current implementation is really a `ranking_score` page with a dynamic eligibility window. +- The page is only as fresh as both the index and the app cache. +- The page no longer uses `GridFiller`, so it should not inject older out-of-window artworks just to pad page 1. \ No newline at end of file diff --git a/public/gfx/skinbase_logo.webp b/public/gfx/skinbase_logo.webp new file mode 100644 index 00000000..6ab17a6f Binary files /dev/null and b/public/gfx/skinbase_logo.webp differ diff --git a/public/gfx/skinbase_logo_128.webp b/public/gfx/skinbase_logo_128.webp new file mode 100644 index 00000000..ce254c1d Binary files /dev/null and b/public/gfx/skinbase_logo_128.webp differ diff --git a/public/gfx/skinbase_logo_256.webp b/public/gfx/skinbase_logo_256.webp new file mode 100644 index 00000000..ce254c1d Binary files /dev/null and b/public/gfx/skinbase_logo_256.webp differ diff --git a/public/gfx/skinbase_logo_32.webp b/public/gfx/skinbase_logo_32.webp new file mode 100644 index 00000000..c94e4ef8 Binary files /dev/null and b/public/gfx/skinbase_logo_32.webp differ diff --git a/public/gfx/skinbase_logo_512.webp b/public/gfx/skinbase_logo_512.webp new file mode 100644 index 00000000..ce515e1a Binary files /dev/null and b/public/gfx/skinbase_logo_512.webp differ diff --git a/public/gfx/skinbase_logo_64.webp b/public/gfx/skinbase_logo_64.webp new file mode 100644 index 00000000..7900b159 Binary files /dev/null and b/public/gfx/skinbase_logo_64.webp differ diff --git a/public/gfx/skinbase_logo_96.webp b/public/gfx/skinbase_logo_96.webp new file mode 100644 index 00000000..bdbd2843 Binary files /dev/null and b/public/gfx/skinbase_logo_96.webp differ diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx index edd6a082..5b80c295 100644 --- a/resources/js/Pages/Studio/StudioArtworkEdit.jsx +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -7,6 +7,7 @@ import Button from '../../components/ui/Button' import Modal from '../../components/ui/Modal' import FormField from '../../components/ui/FormField' import Toggle from '../../components/ui/Toggle' +import NovaSelect from '../../components/ui/NovaSelect' import TagPicker from '../../components/tags/TagPicker' import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker' @@ -286,6 +287,29 @@ export default function StudioArtworkEdit() { selectedRoot?.name || 'No root category', subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null, ].filter(Boolean) + const publishingIdentityOptions = useMemo(() => { + const personalOption = { + value: '', + label: 'Personal profile', + icon: