fix(feeds): trending windows 24/168/720, music newest-first, channel popular tiebreak
- Trending: window options are now 24 / 168 / 720 h (Today / This Week / This Month) and the controller snaps the ?hours param to one of those values instead of clamping to [24, 168] — which silently made the "This Month" tab show one-week data. Ranking is pure in-window view count, with likes and newest createdAt as the tiebreaks (not the old weighted sum of views + velocity + recency bonus + likes, which was impossible to reason about and hid videos that genuinely led the window). - Music + /videos?filter=music: previously the branch skipped the latest() call so Eloquent fell back to id ASC and the music tab opened on the oldest uploads. Now both branches share one ->latest() call. - Channel Popular sort: order by views desc, likes desc, created_at desc so two equally-viewed videos no longer flip order between loads. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a25af30700
commit
133ab6e623
3 files changed
+46
-27
No files matched your search
@@ -246,7 +246,14 @@ class UserController extends Controller
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
// Views first, likes as the tiebreak, created_at as the final
|
||||
// tiebreak so two videos with the same view+like counts don't
|
||||
// flip order between page loads.
|
||||
$baseQuery
|
||||
->withCount(['viewers as viewers_count', 'likes as likes_count'])
|
||||
->orderByDesc('viewers_count')
|
||||
->orderByDesc('likes_count')
|
||||
->latest();
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
|
||||
@@ -163,9 +163,12 @@ class VideoController extends Controller
|
||||
|
||||
// ── Filtered single-type views ────────────────────────────
|
||||
if (in_array($filter, ['music', 'latest'])) {
|
||||
$query = Video::public()->notShorts();
|
||||
// Both tabs sort newest-first. Previously the music branch skipped
|
||||
// the latest() call entirely, so Eloquent fell back to id ASC and
|
||||
// the music grid opened on the oldest uploads — never surfacing
|
||||
// anything new.
|
||||
$query = Video::public()->notShorts()->latest();
|
||||
if ($filter === 'music') $query->where('type', 'music');
|
||||
else $query->latest();
|
||||
$videos = $query->limit(50)->get();
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
@@ -2075,47 +2078,52 @@ class VideoController extends Controller
|
||||
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$hours = $request->get('hours', 48);
|
||||
$limit = $request->get('limit', 50);
|
||||
|
||||
$hours = min(max($hours, 24), 168);
|
||||
$limit = min(max($limit, 10), 100);
|
||||
// Snap the ?hours param to one of the three windows the UI exposes
|
||||
// (Today / This Week / This Month). The old clamp (24..168) silently
|
||||
// capped the "month" tab at a week, which is why the tabs all showed
|
||||
// the same data; now 720 is a first-class option.
|
||||
$hours = (int) $request->get('hours', 24);
|
||||
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
|
||||
$limit = min(max((int) $request->get('limit', 50), 10), 100);
|
||||
|
||||
// Candidate pool: public ready videos created at any time (not just
|
||||
// the last 10 days) — a video uploaded three weeks ago still belongs
|
||||
// in "This Month" if it caught fire this week. We rank by the number
|
||||
// of views INSIDE the window, with the recency bonus/velocity now
|
||||
// used only as tiebreakers.
|
||||
$windowStart = now()->subHours($hours);
|
||||
$videos = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->where('created_at', '>=', now()->subMonths(6))
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$videos = $videos->map(function ($video) use ($hours) {
|
||||
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
|
||||
$recentViews = \DB::table('video_views')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->where('watched_at', '>=', $windowStart)
|
||||
->count();
|
||||
|
||||
$likeCount = \DB::table('video_likes')
|
||||
->where('video_id', $video->id)
|
||||
->count();
|
||||
|
||||
$ageHours = $video->created_at->diffInHours(now());
|
||||
$velocity = $recentViews / $hours;
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||||
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeCount * 0.1 * 0.05);
|
||||
|
||||
$video->trending_score = round($score, 2);
|
||||
$video->view_count = $recentViews;
|
||||
$video->like_count = $likeCount;
|
||||
|
||||
$video->_tiebreak_age = $video->created_at->timestamp;
|
||||
return $video;
|
||||
});
|
||||
|
||||
// Rank: in-window views (primary), likes (tiebreak #1), newest
|
||||
// created_at (tiebreak #2). Any video with zero views in the window
|
||||
// is dropped so the list never surfaces truly dormant content.
|
||||
$trendingVideos = $videos
|
||||
->filter(fn ($v) => $v->trending_score > 0)
|
||||
->sortByDesc('trending_score')
|
||||
->filter(fn ($v) => $v->view_count > 0)
|
||||
->sortBy([
|
||||
['view_count', 'desc'],
|
||||
['like_count', 'desc'],
|
||||
['_tiebreak_age', 'desc'],
|
||||
])
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
|
||||
@@ -177,9 +177,13 @@
|
||||
<div class="trending-header">
|
||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||
<div class="trending-filters">
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||
{{-- Hours line up with the labels: 24 = Today, 168 = This Week
|
||||
(7×24), 720 = This Month (30×24). The old 24/48/168 trio
|
||||
claimed Month but silently showed Week data because the
|
||||
controller clamped the max to 168. --}}
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}" @if($hours == 24) aria-current="page"@endif>Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}" @if($hours == 168) aria-current="page"@endif>This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 720]) }}" class="{{ $hours == 720 ? 'active' : '' }}" @if($hours == 720) aria-current="page"@endif>This Month</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user