DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing a Video Trending Score Algorithm

Introduction

Not all popular videos are trending. A video with 100 million views that got them over 5 years isn't trending. A video with 500,000 views that got them in the last 6 hours is. The difference is velocity, and building a proper trending score algorithm is essential for any video platform. Here's how I built it for ViralVidVault.

The Formula

Our trending score combines three time-decayed factors:

trending_score = (velocity_score * 0.4) + (engagement_score * 0.35) + (recency_score * 0.25)
Enter fullscreen mode Exit fullscreen mode

Each component is normalized to 0-100 before weighting.

Implementation

<?php

class TrendingScorer
{
    private const VELOCITY_WEIGHT = 0.40;
    private const ENGAGEMENT_WEIGHT = 0.35;
    private const RECENCY_WEIGHT = 0.25;

    // Half-life in hours for time decay
    private const HALF_LIFE_HOURS = 12;

    public function score(array $video, array $previousMetrics = []): float
    {
        $velocity = $this->velocityScore($video, $previousMetrics);
        $engagement = $this->engagementScore($video);
        $recency = $this->recencyScore($video);

        return round(
            ($velocity * self::VELOCITY_WEIGHT)
            + ($engagement * self::ENGAGEMENT_WEIGHT)
            + ($recency * self::RECENCY_WEIGHT),
            2
        );
    }

    private function velocityScore(array $video, array $previous): float
    {
        if (empty($previous)) return 0;

        $viewDelta = (int)$video['views'] - (int)$previous['views'];
        $timeDelta = strtotime($video['checked_at']) - strtotime($previous['checked_at']);

        if ($timeDelta <= 0) return 0;

        $viewsPerHour = ($viewDelta / $timeDelta) * 3600;

        // Normalize: 10,000 views/hour = score of 100
        return min(100, ($viewsPerHour / 10000) * 100);
    }

    private function engagementScore(array $video): float
    {
        $views = max(1, (int)$video['views']);
        $likes = (int)($video['likes'] ?? 0);
        $comments = (int)($video['comments'] ?? 0);

        // Engagement rate as percentage, comments weighted 2x
        $rate = (($likes + $comments * 2) / $views) * 100;

        // Normalize: 5% engagement = score of 100
        return min(100, ($rate / 5) * 100);
    }

    private function recencyScore(array $video): float
    {
        $publishedAt = strtotime($video['published_at']);
        $hoursAgo = (time() - $publishedAt) / 3600;

        // Exponential decay with half-life
        $decay = pow(0.5, $hoursAgo / self::HALF_LIFE_HOURS);

        return round($decay * 100, 2);
    }
}
Enter fullscreen mode Exit fullscreen mode

Time Decay: The Secret Ingredient

The recency score uses exponential decay with a 12-hour half-life. This means:

Hours since publish Recency Score
0 100.0
6 70.7
12 50.0
24 25.0
48 6.25
72 1.56

After 3 days, even a high-velocity video gets pushed down by fresher content. This keeps the trending feed feeling dynamic.

Batch Scoring in SQL

For large-scale scoring, doing it in PHP per-video is slow. Here's a SQL approach:

public function batchScore(): void
{
    $this->db->exec('
        UPDATE videos SET trending_score = (
            -- Velocity component (requires previous metrics)
            COALESCE(
                (SELECT
                    MIN(100, ((v2.views - v1.views) / MAX(1, 
                        (CAST(strftime("%s", v2.checked_at) AS INTEGER) - 
                         CAST(strftime("%s", v1.checked_at) AS INTEGER))
                    ) * 3600 / 10000.0) * 100) * 0.4
                FROM video_metrics v1
                JOIN video_metrics v2 ON v1.video_id = v2.video_id
                    AND v2.rowid = v1.rowid + 1
                WHERE v1.video_id = videos.id
                ORDER BY v2.checked_at DESC LIMIT 1),
            0)
            +
            -- Engagement component
            MIN(100, ((CAST(likes AS REAL) + comments * 2) / MAX(1, views) * 100 / 5) * 100) * 0.35
            +
            -- Recency component (12h half-life decay)
            (POWER(0.5, (CAST(strftime("%s", "now") AS INTEGER) - 
                CAST(strftime("%s", published_at) AS INTEGER)) / 43200.0) * 100) * 0.25
        )
        WHERE is_active = 1
    ');
}
Enter fullscreen mode Exit fullscreen mode

Querying the Trending Feed

public function getTrending(int $limit = 20, ?string $category = null): array
{
    $sql = 'SELECT * FROM videos WHERE is_active = 1';
    $params = [];

    if ($category) {
        $sql .= ' AND category_id = :cat';
        $params[':cat'] = $category;
    }

    $sql .= ' ORDER BY trending_score DESC LIMIT :limit';
    $params[':limit'] = $limit;

    $stmt = $this->db->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
Enter fullscreen mode Exit fullscreen mode

Tuning the Weights

The weights (0.4 velocity, 0.35 engagement, 0.25 recency) were tuned by observing what "felt right" on ViralVidVault. If velocity is weighted too high, clickbait dominates. If engagement is too high, niche content with dedicated fans ranks above broadly popular videos. The current balance works well for a general viral video platform.

Explore the trending feed at viralvidvault.com to see this algorithm in action.


Part of the "Building ViralVidVault" series.

Top comments (0)