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:
- Query Meilisearch through
ArtworkSearchService - If the search-backed query throws or returns no items, fall back to a direct MySQL query against
artworks+artwork_stats - Hydrate the returned IDs back into full
ArtworkEloquent models when needed - 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:descengagement_velocity:descviews: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_scoreartwork_stats.engagement_velocityartwork_stats.viewsartwork_stats.downloadsartwork_stats.favoritesartwork_stats.comments_countartwork_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-scoresevery 30 minutesskinbase:flush-redis-statsevery 5 minutes
Indirectly relevant:
artworks:publish-scheduledevery 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_scorepage 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.