middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress']); } public function index() { $filter = request('filter', 'all'); // ── Playlists-only browse ────────────────────────────────── if ($filter === 'playlists') { $playlists = Playlist::where('visibility', 'public') ->withCount('videos') ->with('user') ->latest() ->limit(60) ->get(); return view('videos.index', compact('playlists', 'filter') + [ 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), ]); } // ── Shorts-only browse ──────────────────────────────────── if ($filter === 'shorts') { $videos = Video::public()->shorts()->latest()->limit(60)->get(); return view('videos.index', compact('videos', 'filter') + [ 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), ]); } // ── Sports/Matches-only browse ──────────────────────────── if ($filter === 'match') { $videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get(); return view('videos.index', compact('videos', 'filter') + [ 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), ]); } // ── Filtered single-type views ──────────────────────────── if (in_array($filter, ['music', 'latest'])) { $query = Video::public()->notShorts(); if ($filter === 'music') $query->where('type', 'music'); else $query->latest(); $videos = $query->limit(50)->get(); return view('videos.index', compact('videos', 'filter') + [ 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), ]); } // ── All (home) — mixed chronological feed ───────────────── $videos = Video::public()->latest()->limit(60)->get(); $playlistQuery = Playlist::withCount('videos')->with('user')->latest()->limit(40); $playlistQuery->where('visibility', 'public'); $playlists = $playlistQuery->where('is_default', false)->get() ->filter(fn($pl) => $pl->videos_count > 0) ->values(); // Tag each item with its kind so the view can pick the right card $feedItems = $videos->map(fn($v) => ['kind' => 'video', 'item' => $v, 'date' => $v->created_at]) ->concat($playlists->map(fn($p) => ['kind' => 'playlist', 'item' => $p, 'date' => $p->created_at])) ->sortByDesc('date') ->values(); return view('videos.index', compact('feedItems', 'filter') + [ 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), ]); } public function search(Request $request) { $query = $request->get('q', ''); if (empty($query)) { return redirect()->route('videos.index'); } $matchCondition = function ($q) use ($query) { $q->where('title', 'like', "%{$query}%") ->orWhere('description', 'like', "%{$query}%"); }; $allVideos = Video::public()->where($matchCondition)->latest()->get(); $videos = $allVideos->where('is_shorts', false)->values(); $shorts = $allVideos->where('is_shorts', true)->values(); $playlistQuery = Playlist::where('name', 'like', "%{$query}%") ->withCount('videos') ->with('user') ->latest() ->limit(12); $playlistQuery->where('visibility', 'public'); $playlists = $playlistQuery->get(); return view('videos.index', compact('videos', 'shorts', 'query', 'playlists')); } public function create() { return view('videos.create'); } public function store(Request $request) { try { $audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav']; $uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? ''); $isAudioUpload = in_array($uploadedExt, $audioExtensions); $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'video' => $isAudioUpload ? 'required|file|max:512000' : 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv|max:512000', 'thumbnail' => $isAudioUpload ? 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480' : 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480', 'slides' => $isAudioUpload ? 'required|array|min:1' : 'nullable', 'slides.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480', 'visibility' => 'nullable|in:public,unlisted,private', 'type' => 'nullable|in:generic,music,match', 'download_access' => 'nullable|in:disabled,everyone,registered,subscribers', ]); $videoFile = $request->file('video'); $filename = self::generateFilename($videoFile->getClientOriginalExtension()); $path = $videoFile->storeAs('public/videos', $filename); // Get file info $fileSize = $videoFile->getSize(); $mimeType = $videoFile->getMimeType(); $thumbnailPath = null; $slideFiles = []; if ($isAudioUpload && $request->hasFile('slides')) { // Audio upload: save all slides; first slide becomes the thumbnail foreach ($request->file('slides') as $slideFile) { $fname = self::generateFilename($slideFile->getClientOriginalExtension()); $slideFile->storeAs('public/thumbnails', $fname); $slideFiles[] = $fname; } $thumbnailPath = 'public/thumbnails/' . $slideFiles[0]; } elseif ($request->hasFile('thumbnail')) { $thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension()); $thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename); } elseif (! $isAudioUpload) { try { $ffmpeg = FFMpeg::create(); $videoPath = storage_path('app/'.$path); if (file_exists($videoPath)) { $video = $ffmpeg->open($videoPath); $frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1)); $thumbFilename = Str::uuid().'.jpg'; $thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename); if (! file_exists(storage_path('app/public/thumbnails'))) { mkdir(storage_path('app/public/thumbnails'), 0755, true); } $frame->save($thumbFullPath); $thumbnailPath = 'public/thumbnails/'.$thumbFilename; } } catch (\Exception $e) { \Log::error('FFmpeg thumbnail error: '.$e->getMessage()); } } $width = null; $height = null; $orientation = 'landscape'; if (! $isAudioUpload) { try { $ffprobe = FFProbe::create(); $videoPath = storage_path('app/'.$path); if (file_exists($videoPath)) { $streams = $ffprobe->streams($videoPath); $videoStream = $streams->videos()->first(); if ($videoStream) { $width = $videoStream->get('width'); $height = $videoStream->get('height'); if ($width && $height) { if ($height > $width) $orientation = 'portrait'; elseif ($width > $height) $orientation = 'landscape'; else $orientation = 'square'; } } } } catch (\Exception $e) { \Log::error('FFprobe error: '.$e->getMessage()); } } $video = Video::create([ 'user_id' => Auth::id(), 'title' => $request->title, 'description' => $request->description, 'filename' => $filename, 'path' => $path, 'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null, 'size' => $fileSize, 'mime_type' => $mimeType, 'orientation' => $orientation, 'width' => $width, 'height' => $height, 'status' => $isAudioUpload ? 'ready' : 'processing', 'visibility' => $request->visibility ?? 'public', 'type' => $isAudioUpload ? 'music' : ($request->type ?? 'generic'), 'download_access' => $request->input('download_access', 'disabled'), 'share_token' => Str::random(32), ]); // Save individual slide records for audio uploads with multiple images foreach ($slideFiles as $position => $fname) { VideoSlide::create([ 'video_id' => $video->id, 'filename' => $fname, 'position' => $position, ]); } if (! $isAudioUpload) { CompressVideoJob::dispatch($video) ->onQueue('video-processing') ->onConnection('database'); } try { NasSyncVideoJob::dispatch($video); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage()); } $video->load('user'); $userEmail = Auth::user()->email; $userName = Auth::user()->name; $uploader = Auth::user(); $subscribers = $uploader->subscribers()->get(); $subscriberEmails = $subscribers->pluck('email')->toArray(); // In-app DB notifications (fast — just inserts, no network) if ($video->visibility === 'public') { foreach ($subscribers as $subscriber) { try { $subscriber->notify(new NewVideoUploadedNotification($video, $uploader)); } catch (\Throwable $e) { \Log::error('In-app notification error: '.$e->getMessage()); } } } app()->terminating(function () use ($video, $userEmail, $userName, $uploader, $subscriberEmails) { // Confirm upload to the uploader try { Mail::to($userEmail)->send(new VideoUploaded($video, $userName)); } catch (\Throwable $e) { \Log::error('Email error: '.$e->getMessage()); } // Email subscribers (only for public videos) if ($video->visibility === 'public' && count($subscriberEmails) > 0) { foreach ($subscriberEmails as $email) { try { Mail::to($email)->send(new NewVideoNotification($video, $uploader)); } catch (\Throwable $e) { \Log::error('Subscriber notification error: '.$e->getMessage()); } } } }); AuditLog::record('video.uploaded', [ 'subject_type' => 'Video', 'subject_id' => (string) $video->id, 'subject_label' => $video->title, 'details' => ['type' => $video->type, 'visibility' => $video->visibility], ]); return response()->json([ 'success' => true, 'redirect' => route('videos.show', $video), ]); } catch (\Throwable $e) { \Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString()); return response()->json([ 'success' => false, 'message' => $e->getMessage(), ], 500); } } public function showByToken(Request $request, string $token) { $video = Video::where('share_token', $token)->firstOrFail(); if (! $video->canView(Auth::user())) { abort(404); } return $this->show($request, $video); } public function show(Request $request, Video $video) { if (! $video->canView(Auth::user())) { $message = $video->isPrivate() ? 'This video is private.' : 'This video is no longer available.'; return redirect('/')->with('toast_error', $message); } $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $geo = GeoIpService::lookup($ip); if (Auth::check()) { $exists = \DB::table('video_views') ->where('user_id', Auth::id()) ->where('video_id', $video->id) ->where('watched_at', '>', now()->subHour()) ->exists(); if (! $exists) { \DB::table('video_views')->insert([ 'user_id' => Auth::id(), 'video_id' => $video->id, 'ip_address' => $ip, 'country' => $geo['country'], 'country_name' => $geo['country_name'], 'watched_at' => now(), ]); } } else { // Guest: deduplicate by IP within the last hour $exists = \DB::table('video_views') ->whereNull('user_id') ->where('video_id', $video->id) ->where('ip_address', $ip) ->where('watched_at', '>', now()->subHour()) ->exists(); if (! $exists) { \DB::table('video_views')->insert([ 'user_id' => null, 'video_id' => $video->id, 'ip_address' => $ip, 'country' => $geo['country'], 'country_name' => $geo['country_name'], 'watched_at' => now(), ]); } } $video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides']); $playlist = null; $nextVideo = null; $previousVideo = null; $playlistVideos = null; $playlistParam = $request->query('playlist'); if ($playlistParam) { $playlist = Playlist::where('share_token', $playlistParam)->first(); if ($playlist && $playlist->canViewViaToken(Auth::user())) { $nextVideo = $playlist->getNextVideo($video); $previousVideo = $playlist->getPreviousVideo($video); $playlistVideos = $playlist->videos; } } $recommendedVideos = Video::public() ->where('id', '!=', $video->id) ->latest() ->limit(20) ->get(); $view = match ($video->type) { 'match' => 'videos.types.match', 'music' => 'videos.types.music', default => 'videos.types.generic', }; // Set persistent device-ID cookie used for share-link dedup $did = $request->cookie('_did') ?: (string) Str::uuid(); return response() ->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos')) ->header('Cache-Control', 'no-store, no-cache, must-revalidate') ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); } public function matchData(Video $video) { if (! $video->canView(Auth::user())) { abort(403); } return response()->json([ 'success' => true, 'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(), 'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(), ]); } public function edit(Video $video, Request $request) { if (Auth::id() !== $video->user_id) { abort(403); } if (! $request->expectsJson() && ! $request->ajax()) { return view('videos.edit', compact('video')); } $slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [ 'id' => $s->id, 'url' => asset('storage/thumbnails/' . $s->filename), ])->values(); return response()->json([ 'success' => true, 'video' => [ 'id' => $video->id, 'title' => $video->title, 'description' => $video->description, 'thumbnail' => $video->thumbnail, 'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : null, 'visibility' => $video->visibility ?? 'public', 'type' => $video->type ?? 'generic', 'download_access' => $video->download_access, 'is_audio' => $this->isAudioOnlyFile($video), 'slides' => $slides, ], ]); } public function update(Request $request, Video $video) { if (Auth::id() !== $video->user_id) { abort(403); } $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480', 'visibility' => 'nullable|in:public,unlisted,private', 'type' => 'nullable|in:generic,music,match', 'download_access' => 'nullable|in:disabled,everyone,registered,subscribers', 'slides_add' => 'nullable|array', 'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480', ]); $data = $request->only(['title', 'description', 'visibility', 'type']); $data['download_access'] = $request->input('download_access', 'disabled'); if ($request->hasFile('thumbnail')) { if ($video->thumbnail) { Storage::delete('public/thumbnails/'.$video->thumbnail); } $thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension()); $data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename); $data['thumbnail'] = basename($data['thumbnail']); } if (! isset($data['visibility'])) { unset($data['visibility']); } // Handle slide reorder / removal / additions for audio tracks $slidesChanged = false; if ($this->isAudioOnlyFile($video)) { // slides_order is a JSON array of kept slide IDs in their new order $keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: []; // Delete slides not in the kept list $video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) { Storage::delete('public/thumbnails/' . $slide->filename); $slide->delete(); $slidesChanged = true; }); // Reorder kept slides foreach ($keptOrder as $pos => $slideId) { VideoSlide::where('id', $slideId)->where('video_id', $video->id) ->update(['position' => $pos]); } if (! empty($keptOrder)) $slidesChanged = true; // Add new slides if ($request->hasFile('slides_add')) { $nextPos = count($keptOrder); foreach ($request->file('slides_add') as $file) { $fname = self::generateFilename($file->getClientOriginalExtension()); $file->storeAs('public/thumbnails', $fname); VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos++]); $slidesChanged = true; } } // Keep thumbnail in sync with the first slide $firstSlide = $video->slides()->orderBy('position')->first(); if ($firstSlide) { $data['thumbnail'] = $firstSlide->filename; } // Invalidate cached slideshow video whenever slides change if ($slidesChanged) { @unlink(storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4')); $data['slideshow_video_path'] = null; } } $video->update($data); try { NasSyncVideoJob::dispatch($video->fresh()); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage()); } AuditLog::record('video.updated', [ 'subject_type' => 'Video', 'subject_id' => (string) $video->id, 'subject_label' => $video->title, ]); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Video updated successfully!', 'video' => [ 'id' => $video->id, 'title' => $video->title, 'description' => $video->description, 'visibility' => $video->visibility, 'download_access' => $video->download_access, ], ]); } return redirect()->route('videos.show', $video)->with('success', 'Video updated!'); } public function destroy(Request $request, Video $video) { if (Auth::id() !== $video->user_id) { abort(403); } // If the owner has 2FA enabled, require a valid OTP $user = Auth::user(); if ($user->two_factor_enabled && $user->two_factor_secret) { $code = $request->header('X-2FA-Code') ?? $request->input('otp_code', ''); $google2fa = app('pragmarx.google2fa'); if (! $google2fa->verifyKey(decrypt($user->two_factor_secret), (string) $code)) { return response()->json(['message' => 'Invalid 2FA code. Please try again.'], 422); } } AuditLog::record('video.deleted', [ 'subject_type' => 'Video', 'subject_id' => (string) $video->id, 'subject_label' => $video->title, 'details' => ['owner_id' => $video->user_id, 'type' => $video->type], ]); Storage::delete('public/videos/'.$video->filename); if ($video->thumbnail) { Storage::delete('public/thumbnails/'.$video->thumbnail); } $nasSync = app(\App\Services\NasSyncService::class); if ($nasSync->isEnabled()) { $nasSync->deleteVideo($video); } $video->delete(); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Video deleted successfully!', ]); } return redirect()->route('videos.index')->with('success', 'Video deleted!'); } 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); $videos = Video::public() ->where('status', 'ready') ->where('created_at', '>=', now()->subDays(10)) ->with('user') ->get(); $videos = $videos->map(function ($video) use ($hours) { $recentViews = \DB::table('video_views') ->where('video_id', $video->id) ->where('watched_at', '>=', now()->subHours($hours)) ->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; return $video; }); $trendingVideos = $videos ->filter(fn ($v) => $v->trending_score > 0) ->sortByDesc('trending_score') ->take($limit) ->values(); return view('videos.trending', [ 'videos' => $trendingVideos, 'hours' => $hours, 'limit' => $limit, ]); } public function shorts(Request $request) { $videos = Video::public() ->where('is_shorts', true) ->where('status', 'ready') ->with('user') ->latest() ->get(); return view('videos.shorts', compact('videos')); } public function stream(Video $video) { if (! $video->canView(Auth::user())) { abort(404, 'Video not found'); } $path = storage_path('app/public/videos/'.$video->filename); // If not on local disk, try to pull from NAS (primary storage when NAS is enabled) if (! file_exists($path)) { $nas = app(\App\Services\NasSyncService::class); $path = $nas->ensureLocalCopy($video); if (! $path) { abort(404, 'Video file not found'); } } $fileSize = filesize($path); $mimeType = $video->mime_type ?: 'video/mp4'; $handle = fopen($path, 'rb'); if (! $handle) { abort(500, 'Cannot open video file'); } $range = request()->header('Range'); if ($range) { preg_match('/bytes=(\d+)-(\d*)/', $range, $matches); $start = intval($matches[1] ?? 0); $end = $matches[2] ? intval($matches[2]) : $fileSize - 1; $length = $end - $start + 1; header('HTTP/1.1 206 Partial Content'); header('Content-Type: '.$mimeType); header('Content-Length: '.$length); header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); fseek($handle, $start); $chunkSize = 8192; $bytesToRead = $length; while (! feof($handle) && $bytesToRead > 0) { $buffer = fread($handle, min($chunkSize, $bytesToRead)); echo $buffer; flush(); $bytesToRead -= strlen($buffer); } fclose($handle); exit; } else { header('Content-Type: '.$mimeType); header('Content-Length: '.$fileSize); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); fpassthru($handle); fclose($handle); exit; } } public function hls(Video $video, $file = 'playlist.m3u8') { if (! $video->canView(Auth::user())) { abort(404); } if (! $video->has_hls) { abort(404, 'HLS unavailable'); } $hlsPath = storage_path('app/' . $video->hls_path . '/' . $file); if (! file_exists($hlsPath)) { abort(404); } $mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8' ? 'application/vnd.apple.mpegurl' : 'video/mp2t'; header('Content-Type: '.$mimeType); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Headers: Range'); if (request()->header('Range')) { $size = filesize($hlsPath); preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches); $start = intval($matches[1] ?? 0); $end = $matches[2] ? intval($matches[2]) : $size - 1; $length = $end - $start + 1; header('HTTP/1.1 206 Partial Content'); header('Content-Length: '.$length); header('Content-Range: bytes '.$start.'-'.$end.'/'.$size); $handle = fopen($hlsPath, 'rb'); fseek($handle, $start); echo fread($handle, $length); fclose($handle); } else { header('Content-Length: '.filesize($hlsPath)); readfile($hlsPath); } exit; } private function checkDownloadAccess(Video $video): void { $access = $video->download_access ?? 'disabled'; $user = Auth::user(); match ($access) { 'disabled' => abort(403, 'Downloads are not enabled for this video.'), 'registered' => $user ? null : abort(403, 'You must be logged in to download this video.'), 'subscribers' => ($user && ($user->id === $video->user_id || $user->isSubscribedTo($video->user))) ? null : abort(403, 'You must be subscribed to this channel to download this video.'), default => null, // 'everyone' — allow }; } public function download(Video $video) { $this->checkDownloadAccess($video); $this->recordDownload($video, 'video'); $path = storage_path('app/public/videos/' . $video->filename); // If not on local disk, try to pull from NAS if (! file_exists($path)) { $nas = app(\App\Services\NasSyncService::class); $path = $nas->ensureLocalCopy($video); if (! $path) { abort(404, 'Video file not found.'); } } // Already a video — serve directly, no conversion needed if (! $this->isAudioOnlyFile($video)) { $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; return response()->download($path, $this->safeFilename($video->title, 'video') . '.' . $ext, [ 'Content-Type' => $video->mime_type ?: 'video/mp4', 'Content-Disposition' => $this->contentDisposition($video->title, $ext), ]); } // Audio-only file → generate video // Serve pre-generated slideshow — DB column must confirm it's the current version if ($video->slideshow_video_path) { $slideshowCache = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4'); if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) { return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [ 'Content-Type' => 'video/mp4', 'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'), ]); } // DB says cached but file is gone — clear the stale column $video->update(['slideshow_video_path' => null]); } // Not cached — redirect to the video page which will auto-trigger the // background generation progress bar (startSlideshowDownload). This avoids // blocking the HTTP worker with a synchronous FFmpeg encode. return redirect()->route('videos.show', $video)->with('_auto_dl_video', true); } // ── Background slideshow generation + progress polling ──────── public function slideshowGenerate(Video $video) { $this->checkDownloadAccess($video); if (! $this->isAudioOnlyFile($video)) { return response()->json(['error' => 'Not an audio file'], 422); } $ffmpeg = \App\Models\Setting::ffmpegBinary(); $ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe'); $audioPath = storage_path('app/public/videos/' . $video->filename); if (! file_exists($audioPath)) { return response()->json(['error' => 'Audio file not found'], 404); } $cacheDir = storage_path('app/public/slideshow'); if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true); $outPath = $cacheDir . '/' . $video->id . '_slideshow.mp4'; $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt'; $pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt'; $logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . '.txt'; // Already cached — only trust it when the DB column confirms it's the right version if ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0) { return response()->json(['status' => 'ready']); } // File on disk but DB says it's stale (e.g. slides were edited) — delete it if (file_exists($outPath)) { @unlink($outPath); } // Already running — return so the frontend keeps polling if (file_exists($pidFile)) { $pid = (int) trim(file_get_contents($pidFile)); if ($pid > 0 && file_exists("/proc/{$pid}")) { exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($audioPath) . ' 2>/dev/null', $dO); return response()->json(['status' => 'running', 'duration' => (float) trim($dO[0] ?? '0')]); } } // Probe duration exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($audioPath) . ' 2>/dev/null', $durOut); $dur = isset($durOut[0]) ? (float) trim($durOut[0]) : null; if (! $dur || $dur < 1) { return response()->json(['error' => 'Cannot probe audio duration'], 500); } $slides = $video->slides()->orderBy('position')->get(); $validSlides = $slides->filter(fn($s) => file_exists( storage_path('app/public/thumbnails/' . $s->filename) ))->values(); $scale = 'scale=1280:720:force_original_aspect_ratio=decrease,' . 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p'; @unlink($progressFile); @unlink($pidFile); // Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails $isStillImage = ($validSlides->count() === 1); $vFlags = $this->ffmpegVideoFlags($isStillImage); $cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage); if ($validSlides->count() >= 2) { $n = $validSlides->count(); $fade = max(0.5, min(1.0, ($dur / $n) * 0.15)); $T = ($dur + ($n - 1) * $fade) / $n; $inputs = ''; $scaleFc = []; foreach ($validSlides as $i => $slide) { $imgPath = storage_path('app/public/thumbnails/' . $slide->filename); $inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath); $scaleFc[] = "[{$i}:v]{$scale}[s{$i}]"; } $inputs .= ' -i ' . escapeshellarg($audioPath); $xfadeFc = []; $prev = '[s0]'; for ($i = 1; $i < $n; $i++) { $offset = number_format($i * ($T - $fade), 3); $outLabel = $i === $n - 1 ? '[vout]' : "[v{$i}]"; $xfadeFc[] = "{$prev}[s{$i}]xfade=transition=fade:duration=" . number_format($fade, 3) . ":offset={$offset}{$outLabel}"; $prev = $outLabel; } if ($n === 1) $xfadeFc[] = '[s0]copy[vout]'; $fc = implode(';', array_merge($scaleFc, $xfadeFc)); $cmd = "{$ffmpeg} -y{$inputs}" . ' -filter_complex ' . escapeshellarg($fc) . ' -map [vout] -map ' . $n . ':a' . ' ' . $vFlags . ' -c:a aac -b:a 192k -movflags +faststart -shortest'; } elseif ($validSlides->count() === 1) { $imgPath = storage_path('app/public/thumbnails/' . $validSlides->first()->filename); $cmd = "{$ffmpeg} -y" . ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath) . ' -i ' . escapeshellarg($audioPath) . ' -map 0:v:0 -map 1:a:0 -shortest' . ' -vf ' . escapeshellarg($scale) . ' ' . $vFlags . ' -c:a aac -b:a 192k -movflags +faststart'; } else { $cmd = "{$ffmpeg} -y" . ' -f lavfi -i color=c=black:s=1280x720:r=1' . ' -i ' . escapeshellarg($audioPath) . ' -map 0:v:0 -map 1:a:0 -shortest' . ' ' . $vFlags . ' -c:a aac -b:a 192k -movflags +faststart'; } $cmd .= ' -progress ' . escapeshellarg($progressFile) . ' ' . escapeshellarg($outPath); // When GPU is active, wrap in a bash fallback: if GPU command fails, clear the // progress file and retry immediately with CPU (libx264) so the download still works. if (Setting::gpuEnabled() && $vFlags !== $cpuFlags) { $cpuCmd = str_replace($vFlags, $cpuFlags, $cmd); $inner = $cmd . ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }'; $bgCmd = 'nohup bash -c ' . escapeshellarg($inner) . ' 2>' . escapeshellarg($logFile) . ' & echo $! > ' . escapeshellarg($pidFile); } else { $bgCmd = 'nohup ' . $cmd . ' 2>' . escapeshellarg($logFile) . ' & echo $! > ' . escapeshellarg($pidFile); } exec($bgCmd); return response()->json(['status' => 'started', 'duration' => $dur]); } public function slideshowProgress(Video $video) { $outPath = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4'); $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt'; $pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt'; // Read progress content once $content = file_exists($progressFile) ? file_get_contents($progressFile) : ''; $isDone = strpos($content, 'progress=end') !== false; // If progress says done OR the output file exists and process is gone if (! $isDone && file_exists($outPath) && filesize($outPath) > 0) { $pid = file_exists($pidFile) ? (int) trim(file_get_contents($pidFile)) : 0; if (! ($pid > 0 && file_exists("/proc/{$pid}"))) { $isDone = true; } } if ($isDone && file_exists($outPath) && filesize($outPath) > 0) { if (! $video->slideshow_video_path) { $video->update(['slideshow_video_path' => 'public/slideshow/' . $video->id . '_slideshow.mp4']); } return response()->json(['percent' => 100, 'status' => 'ready']); } if (! $content) { // If a PID was recorded but the process is already gone, generation failed if (file_exists($pidFile)) { $pid = (int) trim(file_get_contents($pidFile)); if ($pid > 0 && ! file_exists("/proc/{$pid}")) { return response()->json(['percent' => 0, 'status' => 'error']); } } return response()->json(['percent' => 0, 'status' => 'waiting']); } // Parse the most-recent out_time_ms value from the progress file preg_match_all('/out_time_ms=(\d+)/', $content, $m); $outTimeMs = ! empty($m[1]) ? (int) end($m[1]) : 0; // FFmpeg's out_time_ms field is actually in microseconds despite its name $totalUs = (float) request()->query('duration', 0) * 1_000_000; $percent = $totalUs > 0 ? min(99, (int) round(($outTimeMs / $totalUs) * 100)) : 2; return response()->json(['percent' => $percent, 'status' => 'processing']); } public function downloadMp3(Video $video) { $this->checkDownloadAccess($video); $path = storage_path('app/public/videos/' . $video->filename); if (! file_exists($path)) { abort(404, 'Video file not found.'); } $slug = $this->safeFilename($video->title, 'audio'); $ffmpeg = \App\Models\Setting::ffmpegBinary(); // Already MP3 — serve directly if (strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)) === 'mp3') { $this->recordDownload($video, 'mp3'); return response()->download($path, $this->safeFilename($video->title, 'audio') . '.mp3', [ 'Content-Type' => 'audio/mpeg', 'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'), ]); } $isAudio = $this->isAudioOnlyFile($video); $hwaccel = $this->ffmpegHwaccelFlags(! $isAudio); $escaped = escapeshellarg($path); $tmp = sys_get_temp_dir() . '/' . \Str::uuid() . '.mp3'; $cmd = "{$ffmpeg} -y {$hwaccel}-i {$escaped}" . " -vn -acodec libmp3lame -q:a 2" . ' ' . escapeshellarg($tmp) . ' 2>/dev/null'; set_time_limit(0); exec($cmd, $out, $exitCode); if ($exitCode !== 0 || ! file_exists($tmp) || filesize($tmp) === 0) { @unlink($tmp); abort(500, 'Failed to generate MP3 file.'); } $this->recordDownload($video, 'mp3'); return response()->download($tmp, $this->safeFilename($video->title, 'audio') . '.mp3', [ 'Content-Type' => 'audio/mpeg', 'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'), ])->deleteFileAfterSend(true); } private function safeFilename(string $title, string $fallback): string { // Strip characters illegal on Windows/Linux/macOS filesystems, then trim $name = preg_replace('/[\\\\\/:\*\?"<>\|]/', '', $title); $name = trim($name); return $name !== '' ? $name : $fallback; } private function contentDisposition(string $title, string $ext): string { $ascii = \Str::slug($title) ?: 'download'; $safe = $this->safeFilename($title, $ascii); $utf8 = rawurlencode($safe . '.' . $ext); return 'attachment; filename="' . $ascii . '.' . $ext . '"; filename*=UTF-8\'\'' . $utf8; } private function downloadSlideshowVideo(Video $video, $slides, string $audioPath, string $ffmpeg) { if ($video->slideshow_video_path) { $cached = storage_path('app/' . $video->slideshow_video_path); if (file_exists($cached) && filesize($cached) > 0) { return response()->download($cached, $this->safeFilename($video->title, 'video') . '.mp4', [ 'Content-Type' => 'video/mp4', 'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'), ]); } } return redirect()->route('videos.show', $video)->with('_auto_dl_video', true); } /** Full video codec flags for raw FFmpeg shell commands (GPU if enabled, else CPU). */ private function ffmpegVideoFlags(bool $stillImage = false): string { return Setting::ffmpegVideoFlags($stillImage); } /** CPU-only codec flags — used as fallback when GPU encoding fails. */ private function ffmpegVideoFlagsCpu(bool $stillImage = false): string { return Setting::ffmpegVideoFlagsCpu($stillImage); } /** hwaccel decode flags — only used when input is a real video file and GPU is on. */ private function ffmpegHwaccelFlags(bool $inputIsVideo): string { return Setting::ffmpegHwaccelFlags($inputIsVideo); } private function isAudioOnlyFile(Video $video): bool { if ($video->mime_type) { return str_starts_with($video->mime_type, 'audio/'); } $audioExts = ['mp3', 'aac', 'm4a', 'ogg', 'wav', 'flac', 'opus', 'wma']; return in_array(strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)), $audioExts); } private function recordDownload(Video $video, string $type): void { $request = request(); $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $userId = Auth::id(); // Deduplicate: browsers/download managers sometimes retry the connection. // Skip recording if the same user (or guest IP) already downloaded this // video+type within the last 10 minutes. $alreadyRecorded = \DB::table('video_downloads') ->where('video_id', $video->id) ->where('type', $type) ->where('downloaded_at', '>=', now()->subMinutes(10)) ->when( $userId !== null, fn ($q) => $q->where('user_id', $userId), fn ($q) => $q->whereNull('user_id')->where('ip_address', $ip) ) ->exists(); if ($alreadyRecorded) { return; } $geo = GeoIpService::lookup($ip); \DB::table('video_downloads')->insert([ 'video_id' => $video->id, 'user_id' => $userId, 'ip_address' => $ip, 'country' => $geo['country'], 'country_name' => $geo['country_name'], 'type' => $type, 'downloaded_at' => now(), ]); \DB::table('videos')->where('id', $video->id)->increment('download_count'); } public function recordShare(Video $video) { $userId = Auth::id(); // Authenticated users reuse their existing share token for this video if ($userId) { $existing = \DB::table('video_shares') ->where('video_id', $video->id) ->where('user_id', $userId) ->first(); if ($existing) { return response()->json(['url' => route('share.access', $existing->token)]); } } // Generate a unique 10-char token do { $token = Str::random(10); } while (\DB::table('video_shares')->where('token', $token)->exists()); \DB::table('video_shares')->insert([ 'video_id' => $video->id, 'user_id' => $userId, 'token' => $token, 'created_at' => now(), ]); return response()->json(['url' => route('share.access', $token)]); } public function accessShare(Request $request, string $token) { $share = \DB::table('video_shares')->where('token', $token)->first(); if (! $share) { return redirect('/'); } $video = Video::find($share->video_id); if (! $video || ! $video->canView(Auth::user())) { return redirect('/'); } // Identify device via persistent cookie; generate one for first-time visitors $did = $request->cookie('_did') ?: (string) Str::uuid(); // Record only the first access from this device for this share link $seen = \DB::table('share_accesses') ->where('share_id', $share->id) ->where('device_id', $did) ->exists(); if (! $seen) { $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $geo = GeoIpService::lookup($ip); \DB::table('share_accesses')->insert([ 'share_id' => $share->id, 'device_id' => $did, 'ip_address' => $ip, 'country' => $geo['country'] ?? null, 'country_name'=> $geo['country_name'] ?? null, 'accessed_at' => now(), ]); } return redirect(route('videos.showByToken', $video->share_token)) ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); } public function ogImage(Video $video) { // If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews if ($video->thumbnail) { $path = storage_path('app/public/thumbnails/' . $video->thumbnail); if (file_exists($path)) { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); // Load source image via GD $src = match($ext) { 'png' => @imagecreatefrompng($path), 'webp' => @imagecreatefromwebp($path), 'gif' => @imagecreatefromgif($path), default => @imagecreatefromjpeg($path), }; if ($src) { $ow = imagesx($src); $oh = imagesy($src); // Fit inside 1200×630, preserving aspect ratio $maxW = 1200; $maxH = 630; $ratio = min($maxW / $ow, $maxH / $oh, 1.0); $nw = (int)round($ow * $ratio); $nh = (int)round($oh * $ratio); $dst = imagecreatetruecolor($nw, $nh); // Preserve transparency before converting to JPEG (fill white) $white = imagecolorallocate($dst, 255, 255, 255); imagefill($dst, 0, 0, $white); imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh); imagedestroy($src); ob_start(); imagejpeg($dst, null, 82); imagedestroy($dst); $jpeg = ob_get_clean(); return response($jpeg, 200, [ 'Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=86400', ]); } } } // Generate a branded 1200×630 fallback card $w = 1200; $h = 630; $img = imagecreatetruecolor($w, $h); $cBg = imagecolorallocate($img, 10, 10, 10); $cRed = imagecolorallocate($img, 230, 30, 30); $cWhite = imagecolorallocate($img, 240, 240, 240); $cGray = imagecolorallocate($img, 120, 120, 120); $cDark = imagecolorallocate($img, 28, 28, 28); imagefill($img, 0, 0, $cBg); // Subtle vignette border imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, $cDark); imagefilledrectangle($img, 4, 4, $w - 5, $h - 5, $cBg); // Red accent top bar imagefilledrectangle($img, 0, 0, $w, 8, $cRed); // Red accent bottom bar imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed); // Play button circle (center) $cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72; imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed); // Play triangle $tri = [ $cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy, ]; imagefilledpolygon($img, $tri, $cWhite); $fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'; $fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; // App name (top-left) imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name'))); // Video title (wrap long titles) $title = $video->title ?: 'Video'; $maxChars = 42; if (mb_strlen($title) > $maxChars) { $lines = []; $words = explode(' ', $title); $line = ''; foreach ($words as $word) { if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; } else { $line .= ($line ? ' ' : '') . $word; } } if ($line) $lines[] = trim($line); } else { $lines = [$title]; } $lines = array_slice($lines, 0, 2); $titleY = $cy + $r + 60; foreach ($lines as $i => $line) { $bbox = imagettfbbox(28, 0, $fontBold, $line); $tw = $bbox[2] - $bbox[0]; $tx = (int)(($w - $tw) / 2); imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line); } // Channel name (below title) $channel = $video->user?->name ?? config('app.name'); $bbox = imagettfbbox(16, 0, $fontNormal, $channel); $tw = $bbox[2] - $bbox[0]; $tx = (int)(($w - $tw) / 2); imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $channel); // Domain (bottom-right) $domain = parse_url(config('app.url'), PHP_URL_HOST) ?? config('app.url'); imagettftext($img, 14, 0, $w - 300, $h - 24, $cGray, $fontNormal, $domain); ob_start(); imagejpeg($img, null, 85); imagedestroy($img); $jpeg = ob_get_clean(); return response($jpeg, 200, [ 'Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=86400', ]); } public function insights(Video $video) { if (Auth::id() !== $video->user_id) { abort(403); } $id = $video->id; $now = now(); $totalViews = \DB::table('video_views')->where('video_id', $id)->count(); $uniqueViewers = \DB::table('video_views') ->where('video_id', $id) ->whereNotNull('user_id') ->distinct('user_id') ->count('user_id'); $viewsToday = \DB::table('video_views') ->where('video_id', $id) ->where('watched_at', '>=', $now->copy()->startOfDay()) ->count(); $viewsThisWeek = \DB::table('video_views') ->where('video_id', $id) ->where('watched_at', '>=', $now->copy()->subDays(7)) ->count(); $viewsLastWeek = \DB::table('video_views') ->where('video_id', $id) ->whereBetween('watched_at', [ $now->copy()->subDays(14), $now->copy()->subDays(7), ]) ->count(); $weekChange = $viewsLastWeek > 0 ? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100) : ($viewsThisWeek > 0 ? 100 : 0); // 14-day daily breakdown $rawDaily = \DB::table('video_views') ->selectRaw("date(watched_at) as day, count(*) as cnt") ->where('video_id', $id) ->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay()) ->groupByRaw("date(watched_at)") ->orderBy('day') ->pluck('cnt', 'day'); $daily = []; for ($i = 13; $i >= 0; $i--) { $d = $now->copy()->subDays($i); $daily[] = [ 'date' => $d->format('Y-m-d'), 'label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0), ]; } // Country breakdown (top 10) $rawCountries = \DB::table('video_views') ->selectRaw("country, country_name, count(*) as cnt") ->where('video_id', $id) ->whereNotNull('country') ->groupBy('country', 'country_name') ->orderByDesc('cnt') ->limit(10) ->get(); $totalGeo = $rawCountries->sum('cnt'); $countries = $rawCountries->map(fn ($c) => [ 'code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt, 'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0, ])->values(); // Peak hour (0-23) across all views $driver = \DB::getDriverName(); $hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)'; $peakRow = \DB::table('video_views') ->selectRaw("{$hourExpr} as hr, count(*) as cnt") ->where('video_id', $id) ->groupByRaw("{$hourExpr}") ->orderByDesc('cnt') ->first(); $peakHour = $peakRow ? (int) $peakRow->hr : null; // Top registered viewers (by view count) $topViewers = \DB::table('video_views') ->join('users', 'users.id', '=', 'video_views.user_id') ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at') ->where('video_views.video_id', $id) ->whereNotNull('video_views.user_id') ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username') ->orderByDesc('cnt') ->limit(20) ->get() ->map(fn ($u) => [ 'id' => $u->id, 'channel' => $u->username, 'name' => $u->name, 'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id, 'count' => (int) $u->cnt, 'last_at' => $u->last_at, ]); $guestViews = \DB::table('video_views') ->where('video_id', $id) ->whereNull('user_id') ->count(); // 10 most recent views $recentViewers = \DB::table('video_views') ->leftJoin('users', 'users.id', '=', 'video_views.user_id') ->select( 'video_views.watched_at', 'video_views.country', 'users.id as user_id', 'users.name as user_name', 'users.avatar as user_avatar', 'users.username as user_channel' ) ->where('video_views.video_id', $id) ->orderByDesc('video_views.watched_at') ->limit(10) ->get() ->map(fn ($r) => [ 'at' => $r->watched_at, 'country' => $r->country, 'user_id' => $r->user_id, 'user_channel' => $r->user_channel, 'user_name' => $r->user_name ?? 'Guest', 'user_avatar' => $r->user_id ? ($r->user_avatar ? asset('storage/avatars/' . $r->user_avatar) : 'https://i.pravatar.cc/150?u=' . $r->user_id) : null, ]); // Download details $totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count(); // Per-type breakdown $dlByType = \DB::table('video_downloads') ->selectRaw('type, count(*) as cnt') ->where('video_id', $id) ->groupBy('type') ->pluck('cnt', 'type'); // Per-user download counts (top 20 logged-in users) $dlUsers = \DB::table('video_downloads') ->join('users', 'users.id', '=', 'video_downloads.user_id') ->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at') ->where('video_downloads.video_id', $id) ->whereNotNull('video_downloads.user_id') ->groupBy('users.id', 'users.name', 'users.avatar') ->orderByDesc('cnt') ->limit(20) ->get() ->map(fn ($u) => [ 'id' => $u->id, 'name' => $u->name, 'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id, 'count' => (int) $u->cnt, 'last_at' => $u->last_at, ]); $dlGuests = \DB::table('video_downloads') ->where('video_id', $id) ->whereNull('user_id') ->count(); // 10 most recent downloads $dlRecent = \DB::table('video_downloads') ->leftJoin('users', 'users.id', '=', 'video_downloads.user_id') ->select( 'video_downloads.id', 'video_downloads.type', 'video_downloads.downloaded_at', 'video_downloads.country', 'video_downloads.country_name', 'users.id as user_id', 'users.name as user_name', 'users.avatar as user_avatar' ) ->where('video_downloads.video_id', $id) ->orderByDesc('video_downloads.downloaded_at') ->limit(10) ->get() ->map(fn ($r) => [ 'type' => $r->type, 'at' => $r->downloaded_at, 'country' => $r->country, 'country_name'=> $r->country_name, 'user_id' => $r->user_id, 'user_name' => $r->user_name ?? 'Guest', 'user_avatar'=> $r->user_id ? ($r->user_avatar ? asset('storage/avatars/' . $r->user_avatar) : 'https://i.pravatar.cc/150?u=' . $r->user_id) : null, ]); // Gender breakdown (authenticated viewers only) $genderRows = \DB::table('video_views') ->join('users', 'users.id', '=', 'video_views.user_id') ->selectRaw('users.gender, count(*) as cnt') ->where('video_views.video_id', $id) ->whereNotNull('video_views.user_id') ->whereNotNull('users.gender') ->groupBy('users.gender') ->get(); $totalGender = $genderRows->sum('cnt'); $genders = $genderRows->map(fn ($g) => [ 'gender' => $g->gender, 'count' => (int) $g->cnt, 'pct' => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0, ])->values(); // Age group breakdown (authenticated viewers with birthday set) $ageExpr = $driver === 'sqlite' ? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)" : "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)"; $rawAges = \DB::table('video_views') ->join('users', 'users.id', '=', 'video_views.user_id') ->selectRaw("{$ageExpr} as age") ->where('video_views.video_id', $id) ->whereNotNull('video_views.user_id') ->whereNotNull('users.birthday') ->get(); $ageBuckets = ['Under 13' => 0, '13–17' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55–64' => 0, '65+' => 0]; foreach ($rawAges as $row) { $age = (int) $row->age; $bucket = match (true) { $age < 13 => 'Under 13', $age <= 17 => '13–17', $age <= 24 => '18–24', $age <= 34 => '25–34', $age <= 44 => '35–44', $age <= 54 => '45–54', $age <= 64 => '55–64', default => '65+', }; $ageBuckets[$bucket]++; } $totalAge = array_sum($ageBuckets); $ageGroups = collect($ageBuckets) ->filter(fn ($cnt) => $cnt > 0) ->map(fn ($cnt, $label) => [ 'label' => $label, 'count' => $cnt, 'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0, ])->values(); // ── Share analytics ──────────────────────────────────────── $shareLinks = \DB::table('video_shares')->where('video_id', $id)->get(); $shareIds = $shareLinks->pluck('id'); $shareReach = $shareIds->isEmpty() ? 0 : \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count(); $shareLinksCount = $shareLinks->count(); // Per-link breakdown for insights panel $shareBreakdown = $shareLinks->map(function ($s) { $accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count(); $sharer = $s->user_id ? \DB::table('users')->where('id', $s->user_id)->value('name') : 'Guest'; return [ 'token' => $s->token, 'sharer' => $sharer, 'reach' => $accesses, 'created_at'=> $s->created_at, ]; })->sortByDesc('reach')->values()->take(10); return response()->json([ 'total_views' => $totalViews, 'unique_viewers' => $uniqueViewers, 'top_viewers' => $topViewers, 'guest_views' => $guestViews, 'recent_viewers' => $recentViewers, 'views_today' => $viewsToday, 'views_this_week' => $viewsThisWeek, 'views_last_week' => $viewsLastWeek, 'week_change' => $weekChange, 'downloads' => $totalDownloads, 'dl_video' => (int) ($dlByType->get('video') ?? 0), 'dl_mp3' => (int) ($dlByType->get('mp3') ?? 0), 'dl_users' => $dlUsers, 'dl_guests' => $dlGuests, 'dl_recent' => $dlRecent, 'shares' => $shareReach, 'share_links' => $shareLinksCount, 'share_breakdown' => $shareBreakdown, 'countries' => $countries, 'daily' => $daily, 'peak_hour' => $peakHour, 'likes' => $video->like_count, 'genders' => $genders, 'age_groups' => $ageGroups, ]); } // ── Drill-down: viewers from one country ────────────────────────────── public function insightsCountry(Video $video, string $country) { if (Auth::id() !== $video->user_id) abort(403); $id = $video->id; $country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2)); $totalViews = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count(); $countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name'); $guestCount = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count(); $registeredUsers = \DB::table('video_views') ->join('users', 'users.id', '=', 'video_views.user_id') ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at') ->where('video_views.video_id', $id) ->where('video_views.country', $country) ->whereNotNull('video_views.user_id') ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username') ->orderByDesc('cnt') ->limit(20) ->get() ->map(fn ($u) => [ 'id' => $u->id, 'channel' => $u->username, 'name' => $u->name, 'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id, 'count' => (int) $u->cnt, 'last_at' => $u->last_at, ]); // 14-day trend for this country $now = now(); $rawDaily = \DB::table('video_views') ->selectRaw("date(watched_at) as day, count(*) as cnt") ->where('video_id', $id) ->where('country', $country) ->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay()) ->groupByRaw("date(watched_at)") ->orderBy('day') ->pluck('cnt', 'day'); $daily = []; for ($i = 13; $i >= 0; $i--) { $d = $now->copy()->subDays($i); $daily[] = ['label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0)]; } return response()->json([ 'country' => $country, 'country_name' => $countryName ?? $country, 'total_views' => $totalViews, 'registered_users' => $registeredUsers, 'guest_count' => $guestCount, 'daily' => $daily, ]); } // ── Drill-down: viewers on a specific day ───────────────────────────── public function insightsDay(Video $video, string $date) { if (Auth::id() !== $video->user_id) abort(403); try { $day = \Carbon\Carbon::createFromFormat('Y-m-d', $date, config('app.timezone')); } catch (\Throwable) { abort(400, 'Invalid date'); } $id = $video->id; $start = $day->copy()->startOfDay(); $end = $day->copy()->endOfDay(); $totalViews = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->count(); $guestCount = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->whereNull('user_id')->count(); $registeredUsers = \DB::table('video_views') ->join('users', 'users.id', '=', 'video_views.user_id') ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at') ->where('video_views.video_id', $id) ->whereBetween('video_views.watched_at', [$start, $end]) ->whereNotNull('video_views.user_id') ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username') ->orderByDesc('cnt') ->limit(20) ->get() ->map(fn ($u) => [ 'id' => $u->id, 'channel' => $u->username, 'name' => $u->name, 'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id, 'count' => (int) $u->cnt, 'last_at' => $u->last_at, ]); // Hourly breakdown (0-23) $driver = \DB::getDriverName(); $hourExpr = $driver === 'sqlite' ? "CAST(strftime('%H', watched_at) AS INTEGER)" : 'HOUR(watched_at)'; $rawHourly = \DB::table('video_views') ->selectRaw("{$hourExpr} as hr, count(*) as cnt") ->where('video_id', $id) ->whereBetween('watched_at', [$start, $end]) ->groupByRaw($hourExpr) ->orderBy('hr') ->pluck('cnt', 'hr'); $hourly = []; for ($h = 0; $h < 24; $h++) { $hourly[] = [ 'hour' => $h, 'label' => ($h === 0 ? '12am' : ($h < 12 ? $h . 'am' : ($h === 12 ? '12pm' : ($h - 12) . 'pm'))), 'count' => (int) ($rawHourly->get($h) ?? 0), ]; } // Top countries that day $countries = \DB::table('video_views') ->selectRaw("country, country_name, count(*) as cnt") ->where('video_id', $id) ->whereBetween('watched_at', [$start, $end]) ->whereNotNull('country') ->groupBy('country', 'country_name') ->orderByDesc('cnt') ->limit(5) ->get() ->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]); return response()->json([ 'date' => $day->format('M d, Y'), 'day_of_week' => $day->format('l'), 'total_views' => $totalViews, 'registered_users' => $registeredUsers, 'guest_count' => $guestCount, 'hourly' => $hourly, 'countries' => $countries, ]); } // ── Drill-down: one user's full download history on this video ──────── public function insightsDownloaderHistory(Video $video, int $userId) { if (Auth::id() !== $video->user_id) abort(403); $user = \App\Models\User::findOrFail($userId); $records = \DB::table('video_downloads') ->where('video_id', $video->id) ->where('user_id', $userId) ->orderByDesc('downloaded_at') ->get(['id', 'type', 'country', 'country_name', 'downloaded_at']) ->map(fn ($r) => [ 'type' => $r->type, 'country' => $r->country, 'country_name' => $r->country_name, 'at' => $r->downloaded_at, ]); return response()->json([ 'user' => [ 'id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar ? asset('storage/avatars/' . $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id, ], 'total' => $records->count(), 'records' => $records, ]); } }