From 133ab6e623a168a7b128e8e0a452f1204b01d204 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 10:06:54 +0300 Subject: [PATCH] fix(feeds): trending windows 24/168/720, music newest-first, channel popular tiebreak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/Http/Controllers/UserController.php | 9 +++- app/Http/Controllers/VideoController.php | 58 +++++++++++++---------- resources/views/videos/trending.blade.php | 10 ++-- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 4755cb2..a73333b 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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(); diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 23f0188..2f63a02 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -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->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(); diff --git a/resources/views/videos/trending.blade.php b/resources/views/videos/trending.blade.php index d368cac..f84e387 100644 --- a/resources/views/videos/trending.blade.php +++ b/resources/views/videos/trending.blade.php @@ -177,9 +177,13 @@