Files
2026-04-18 17:02:56 +02:00

3.6 KiB

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:

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.