Add video engagement tracking and heatmap

This commit is contained in:
2026-07-19 20:13:55 +02:00
parent 4f81164b44
commit a5bf2ac245
9 changed files with 491 additions and 0 deletions
@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Episode;
use App\Models\VideoEngagement;
use App\Models\Watched;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
class StreamApiController extends Controller
{
@@ -70,4 +73,71 @@ class StreamApiController extends Controller
return response()->json(['watched' => true], 200);
}
/**
* Track engagement segments that the authenticated user has watched.
* Called periodically client-side while the video is playing.
* Segments are 10-second chunks; segment 0 (0-10s) is excluded.
*/
public function trackEngagement(Request $request): JsonResponse
{
$validated = $request->validate([
'episode_id' => 'required|integer|exists:episodes,id',
'segments' => 'required|array',
'segments.*' => 'integer|min:1',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['tracked' => 0], 401);
}
$rateLimitKey = 'engagement:'.$user->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
return response()->json([
'tracked' => 0,
'message' => 'Rate limit exceeded. Try again in '.$seconds.' seconds.',
], 429);
}
RateLimiter::hit($rateLimitKey, 60);
$episodeId = $request->input('episode_id');
$segments = $request->input('segments');
$tracked = 0;
foreach ($segments as $segment) {
// upsert: insert if not exists, otherwise ignore (unique constraint prevents dupes)
VideoEngagement::firstOrCreate([
'episode_id' => $episodeId,
'user_id' => $user->id,
'segment' => $segment,
]);
$tracked++;
}
return response()->json(['tracked' => $tracked], 200);
}
/**
* Get engagement heatmap data for an episode.
* Returns watch counts per segment, aggregated across all users.
*/
public function getEngagement(int $episodeId): JsonResponse
{
$episode = Episode::findOrFail($episodeId);
$data = cache()->remember(
"engagement:{$episodeId}",
600, // 10-minute cache
fn () => VideoEngagement::where('episode_id', $episodeId)
->select('segment', DB::raw('count(*) as watch_count'))
->groupBy('segment')
->pluck('watch_count', 'segment')
->toArray()
);
return response()->json($data, 200);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VideoEngagement extends Model
{
public $table = 'video_engagement';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = ['episode_id', 'user_id', 'segment'];
/**
* Get the Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class, 'episode_id');
}
/**
* Get the User.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}