Files
hstream/app/Http/Controllers/Api/StreamApiController.php

144 lines
4.7 KiB
PHP

<?php
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
{
/**
* Get Data used by the Video Player.
*/
public function getStream(Request $request)
{
$validated = $request->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);
}
}