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:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:06:54 +03:00
1 parent a25af30700
commit 133ab6e623
3 files changed
+46 -27

No files matched your search

+8 -1
View File
@@ -246,7 +246,14 @@ class UserController extends Controller
switch ($sort) { switch ($sort) {
case 'popular': 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; break;
case 'oldest': case 'oldest':
$baseQuery->oldest(); $baseQuery->oldest();
+31 -23
View File
@@ -163,9 +163,12 @@ class VideoController extends Controller
// ── Filtered single-type views ──────────────────────────── // ── Filtered single-type views ────────────────────────────
if (in_array($filter, ['music', 'latest'])) { 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'); if ($filter === 'music') $query->where('type', 'music');
else $query->latest();
$videos = $query->limit(50)->get(); $videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter', 'langChips') + [ return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
@@ -2075,47 +2078,52 @@ class VideoController extends Controller
public function trending(Request $request) public function trending(Request $request)
{ {
$hours = $request->get('hours', 48); // Snap the ?hours param to one of the three windows the UI exposes
$limit = $request->get('limit', 50); // (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
$hours = min(max($hours, 24), 168); // the same data; now 720 is a first-class option.
$limit = min(max($limit, 10), 100); $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() $videos = Video::public()
->where('status', 'ready') ->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10)) ->where('created_at', '>=', now()->subMonths(6))
->with('user') ->with('user')
->get(); ->get();
$videos = $videos->map(function ($video) use ($hours) { $videos = $videos->map(function ($video) use ($hours, $windowStart) {
$recentViews = \DB::table('video_views') $recentViews = \DB::table('video_views')
->where('video_id', $video->id) ->where('video_id', $video->id)
->where('watched_at', '>=', now()->subHours($hours)) ->where('watched_at', '>=', $windowStart)
->count(); ->count();
$likeCount = \DB::table('video_likes') $likeCount = \DB::table('video_likes')
->where('video_id', $video->id) ->where('video_id', $video->id)
->count(); ->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->view_count = $recentViews;
$video->like_count = $likeCount; $video->like_count = $likeCount;
$video->_tiebreak_age = $video->created_at->timestamp;
return $video; 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 $trendingVideos = $videos
->filter(fn ($v) => $v->trending_score > 0) ->filter(fn ($v) => $v->view_count > 0)
->sortByDesc('trending_score') ->sortBy([
['view_count', 'desc'],
['like_count', 'desc'],
['_tiebreak_age', 'desc'],
])
->take($limit) ->take($limit)
->values(); ->values();
+7 -3
View File
@@ -177,9 +177,13 @@
<div class="trending-header"> <div class="trending-header">
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1> <h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
<div class="trending-filters"> <div class="trending-filters">
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a> {{-- Hours line up with the labels: 24 = Today, 168 = This Week
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a> (7×24), 720 = This Month (30×24). The old 24/48/168 trio
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a> 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>
</div> </div>