validate([ 'episode_id' => 'required', ]); $episode = Episode::where('id', $request->input('episode_id'))->firstOrFail(); $subtitles = $episode->subtitles ->mapWithKeys(fn ($sub) => [$sub->subtitle->slug => $sub->subtitle->name]) ->toArray(); return response()->json([ 'title' => $episode->title.' - '.$episode->episode, 'poster' => $episode->gallery()->first()->image_url, 'interpolated' => $episode->interpolated, 'interpolated_uhd' => $episode->interpolated_uhd, 'stream_url' => $episode->dmca_takedown ? 'stuff/dmca' : $episode->url, 'stream_domains' => config('hstream.stream_domain'), 'asia_stream_domains' => config('hstream.asia_stream_domain'), 'extra_subtitles' => $subtitles, ], 200); } /** * Track that the authenticated user has watched the episode. * Called client-side after 10 seconds of playback. */ public function trackWatched(Request $request): JsonResponse { $validated = $request->validate([ 'episode_id' => 'required|integer|exists:episodes,id', ]); $user = Auth::user(); if (! $user) { return response()->json(['watched' => false], 401); } $episodeId = $request->input('episode_id'); // 1-hour cooldown to prevent duplicate entries $time = Carbon::now()->subHour(1); $alreadyWatched = Watched::where('user_id', $user->id) ->where('episode_id', $episodeId) ->where('created_at', '>=', $time) ->exists(); if (! $alreadyWatched) { Watched::create(['user_id' => $user->id, 'episode_id' => $episodeId]); cache()->forget('user'.$user->id.'watched'.$episodeId); } 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); } }