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

181 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
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
*
* Powers the /rss/discover/* feeds (spec §3.2).
*
* GET /rss/discover → fresh/latest (default)
* GET /rss/discover/trending → trending by trending_score_7d
* GET /rss/discover/fresh → latest published
* GET /rss/discover/rising → rising by heat_score
*/
final class DiscoverFeedController extends Controller
{
public function __construct(
private readonly RSSFeedBuilder $builder,
private readonly AdaptiveTimeWindow $timeWindow,
) {}
/** /rss/discover → redirect to fresh */
public function index(): Response
{
return $this->fresh();
}
/** /rss/discover/trending */
public function trending(): Response
{
$feedUrl = url('/rss/discover/trending');
$artworks = Cache::remember('rss:discover:trending', 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.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Trending Artworks',
'The most-viewed and trending artworks on Skinbase over the past 7 days.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/fresh */
public function fresh(): Response
{
$feedUrl = url('/rss/discover/fresh');
$artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Fresh Uploads',
'The latest artworks just published on Skinbase.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/rising */
public function rising(): Response
{
$feedUrl = url('/rss/discover/rising');
$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(
'Rising Artworks',
'Fastest-growing artworks gaining momentum on Skinbase right now.',
$feedUrl,
$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');
}
}