Files
SkinbaseNova/docs/Discover/trending.md
2026-04-09 08:50:36 +02:00

125 lines
3.6 KiB
Markdown

# 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.