middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']); } public function index() { $filter = request('filter', 'all'); // ── Playlists-only browse ────────────────────────────────── if ($filter === 'playlists') { $playlists = Playlist::where('visibility', 'public') ->withCount('videos') ->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)]) ->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', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)]) ->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', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)]) ->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' : 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv', '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', 'primary_language' => 'nullable|string|max:10', 'extra_track_files' => 'nullable|array', 'extra_track_files.*' => 'file', 'extra_track_languages' => 'nullable|array', 'extra_track_languages.*' => 'nullable|string|max:10', 'extra_track_titles' => 'nullable|array', 'extra_track_titles.*' => 'nullable|string|max:255', 'extra_track_descriptions' => 'nullable|array', 'extra_track_descriptions.*'=> 'nullable|string', ]); $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), 'language' => $request->input('primary_language') ?: null, ]); // 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, ]); } $nas = app(\App\Services\NasSyncService::class); $nasUploadSucceeded = false; if ($nas->isEnabled()) { // ── NAS-primary: push directly to NAS, delete local temp files ── try { $video->load('slides'); // Build slide abs-paths map for audio uploads $slideAbsPaths = []; foreach ($slideFiles as $pos => $fname) { $slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname); } $tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null; // For audio, thumbnail IS slide 0 — don't pass separately (handled via slides) if ($isAudioUpload) $tempThumbAbs = null; $nas->uploadDirectToNas( $video, storage_path('app/' . $path), $tempThumbAbs, $slideAbsPaths ); $video->refresh(); $nasUploadSucceeded = true; } catch (\Throwable $e) { \Log::error('uploadDirectToNas failed (falling back to local): ' . $e->getMessage()); // NAS went down mid-upload — organise the surviving local files below } } if ($nasUploadSucceeded) { // For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local) if (! $isAudioUpload) { \App\Jobs\GenerateHlsJob::dispatch($video->fresh()) ->onQueue('video-processing') ->onConnection('database'); } } else { // ── Local storage: move into NAS-mirrored local directory schema ── try { $video->load('slides'); $nas->organizeLocalFiles($video); $video->refresh(); } catch (\Throwable $e) { \Log::error('organizeLocalFiles failed: ' . $e->getMessage()); } // Compress + HLS pipeline for local storage if (! $isAudioUpload) { CompressVideoJob::dispatch($video) ->onQueue('video-processing') ->onConnection('database'); } } // ── Extra language audio tracks ─────────────────────────────────────── if ($isAudioUpload && $request->hasFile('extra_track_files')) { $nas = app(\App\Services\NasSyncService::class); $trackFiles = $request->file('extra_track_files'); $trackLangs = $request->input('extra_track_languages', []); $trackTitles = $request->input('extra_track_titles', []); $trackDescs = $request->input('extra_track_descriptions', []); foreach ($trackFiles as $i => $trackFile) { if (! $trackFile || ! $trackFile->isValid()) continue; $lang = $trackLangs[$i] ?? 'en'; $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3'); $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null; $desc = !empty($trackDescs[$i]) ? $trackDescs[$i] : null; // Create a placeholder record to get the DB ID for the filename $track = VideoAudioTrack::create([ 'video_id' => $video->id, 'language' => $lang, 'label' => strtoupper($lang), 'title' => $title, 'description' => $desc, 'path' => '__pending__', 'filename' => '__pending__', ]); if ($nas->isEnabled()) { try { $userSlug = $nas->userSlug($video->user); $videoDir = $nas->resolveVideoDir($video); $nasDir = "{$videoDir}/tracks"; $nas->mkdirp($nasDir); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempAbs = storage_path('app/' . $tempPath); $nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nas->putFile($tempAbs, $nasPath); @unlink($tempAbs); $track->update([ 'path' => $nasPath, 'filename' => "{$track->id}.{$ext}", ]); } catch (\Throwable $e) { \Log::error("Extra track NAS upload failed: " . $e->getMessage()); // Fall back to local storage $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); } } else { $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); } } } $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 playerData(Video $video, Request $request) { if (! $video->canView(Auth::user())) { abort(403); } $coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png'); $slides = $video->slides->count() > 1 ? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all() : []; $allLangData = \App\Data\Languages::all(); $audioTracks = $video->audioTracks->map(fn ($t) => [ 'id' => $t->id, 'language' => $t->language, 'label' => $t->label, 'flag' => $allLangData[$t->language]['flag'] ?? null, 'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp, 'title' => $t->title ?? '', 'description' => $t->description ?? '', 'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp, ])->values()->all(); return response()->json([ 'id' => $video->id, 'key' => $video->getRouteKey(), 'type' => $video->type, 'has_hls' => (bool) $video->has_hls, 'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null, 'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp, 'cover_url' => $coverUrl, 'slides' => $slides, 'title' => $video->title, 'author' => $video->user->name ?? '', 'duration' => $video->duration, 'description' => $video->description ?? '', 'download_url' => route('videos.downloadMp3', $video), 'language' => $video->language, 'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null, 'audio_tracks' => $audioTracks, ]); } 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' => $s->url, ])->values(); $audioTracks = $video->audioTracks->map(fn ($t) => [ 'id' => $t->id, 'language' => $t->language, 'label' => $t->label, 'title' => $t->title ?? '', 'description' => $t->description ?? '', 'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]), ])->values(); return response()->json([ 'success' => true, 'video' => [ 'id' => $video->id, 'title' => $video->title, 'description' => $video->description, 'thumbnail' => $video->thumbnail, 'thumbnail_url' => $video->thumbnail_url, 'visibility' => $video->visibility ?? 'public', 'type' => $video->type ?? 'generic', 'download_access' => $video->download_access, 'is_audio' => $this->isAudioOnlyFile($video), 'slides' => $slides, 'language' => $video->language, 'audio_tracks' => $audioTracks, ], ]); } 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', 'primary_language' => 'nullable|string|max:10', 'extra_track_files' => 'nullable|array', 'extra_track_files.*' => 'file', 'extra_track_languages' => 'nullable|array', 'extra_track_languages.*' => 'nullable|string|max:10', 'extra_track_titles' => 'nullable|array', 'extra_track_titles.*'=> 'nullable|string|max:255', 'track_title_updates' => 'nullable|array', 'track_title_updates.*' => 'nullable|string|max:255', 'track_description_updates' => 'nullable|array', 'track_description_updates.*' => 'nullable|string', 'track_language_updates' => 'nullable|array', 'track_language_updates.*' => 'nullable|string|max:10', 'track_file_updates' => 'nullable|array', 'track_file_updates.*' => 'nullable|file', 'promote_track_id' => 'nullable|integer', 'delete_track_ids' => 'nullable|array', 'delete_track_ids.*' => 'integer', ]); $oldTitle = $video->title; $data = $request->only(['title', 'description', 'visibility', 'type']); $data['download_access'] = $request->input('download_access', 'disabled'); if ($request->has('primary_language')) { $data['language'] = $request->input('primary_language') ?: null; } if ($request->hasFile('thumbnail')) { if ($video->thumbnail) { Storage::delete($video->thumbnailStorageKey()); } $nas = app(\App\Services\NasSyncService::class); $ext = strtolower($request->file('thumbnail')->getClientOriginalExtension() ?: 'jpg'); if ($nas->isEnabled()) { // Push directly to NAS — use video root dir (handles promoted-track paths too) $nasDir = $this->nasVideoDir($video, $nas); $tempPath = $request->file('thumbnail')->storeAs('public/tmp', "thumb_{$video->id}.{$ext}"); $tempAbs = storage_path('app/' . $tempPath); $nas->mkdirp($nasDir); if ($nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}")) { @unlink($tempAbs); $data['thumbnail'] = "{$nasDir}/thumb.{$ext}"; } else { // NAS push failed — fall back to local (auto-sync will retry) $localDir = $nas->localVideoDir($video); @mkdir($localDir, 0755, true); rename($tempAbs, "{$localDir}/thumb.{$ext}"); $userSlug = $nas->userSlug($video->user); $data['thumbnail'] = "{$nasDir}/thumb.{$ext}"; } } else { // NAS disabled — keep on local disk $localDir = $nas->localVideoDir($video); @mkdir($localDir, 0755, true); $request->file('thumbnail')->move($localDir, "thumb.{$ext}"); $userSlug = $nas->userSlug($video->user); $nasDir = $this->nasVideoDir($video, $nas); $data['thumbnail'] = "{$nasDir}/thumb.{$ext}"; } } 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) use (&$slidesChanged) { $nas = app(\App\Services\NasSyncService::class); if ($nas->isEnabled() && str_starts_with($slide->filename, 'users/')) { try { $nas->deleteFile($slide->filename); } catch (\Throwable $e) {} } Storage::delete($slide->storageKey()); $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); $nasForSlides = app(\App\Services\NasSyncService::class); $nasEnabled = $nasForSlides->isEnabled(); // Derive NAS dir without scanning when possible $nasDir = $nasEnabled ? $this->nasVideoDir($video, $nasForSlides) : null; // Local dir used as fallback or when NAS is off $localDir = $nasForSlides->localVideoDir($video); $userSlug = $nasForSlides->userSlug($video->user); $relBase = $this->nasVideoDir($video, $nasForSlides); if ($nasEnabled) { $nasForSlides->mkdirp("{$nasDir}/slides"); } else { @mkdir("{$localDir}/slides", 0755, true); } foreach ($request->file('slides_add') as $file) { $ext = strtolower($file->getClientOriginalExtension() ?: 'jpg'); $slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]); if ($nasEnabled) { // Push directly to NAS using position-based filename (matches MediaController) $nasSlide = "{$nasDir}/slides/{$nextPos}.{$ext}"; $tempPath = $file->storeAs('public/tmp', "slide_{$slide->id}.{$ext}"); $tempAbs = storage_path('app/' . $tempPath); if ($nasForSlides->putFile($tempAbs, $nasSlide)) { @unlink($tempAbs); $slide->update(['filename' => $nasSlide]); } else { // NAS push failed — save locally, auto-sync will retry @mkdir("{$localDir}/slides", 0755, true); rename($tempAbs, "{$localDir}/slides/{$slide->id}.{$ext}"); $slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]); } } else { // NAS off — store in local project folder $file->move("{$localDir}/slides", "{$slide->id}.{$ext}"); $slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]); } $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; } } // ── Audio track management (delete + add) ──────────────────────────── if ($this->isAudioOnlyFile($video)) { $nas = app(\App\Services\NasSyncService::class); // Delete tracks requested for removal $deleteIds = $request->input('delete_track_ids', []); if (! empty($deleteIds)) { $video->audioTracks()->whereIn('id', $deleteIds)->each(function ($track) use ($nas) { if ($track->path !== '__pending__') { if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) { try { $nas->deleteFile($track->path); } catch (\Throwable $e) {} } else { @unlink($track->localPath()); } } $track->delete(); }); } // Update titles, descriptions, and languages of existing tracks $titleUpdates = $request->input('track_title_updates', []); $descUpdates = $request->input('track_description_updates', []); $languageUpdates = $request->input('track_language_updates', []); $allTrackIds = array_unique(array_merge( array_keys($titleUpdates), array_keys($descUpdates), array_keys($languageUpdates) )); foreach ($allTrackIds as $trackId) { $fields = []; if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null; if (array_key_exists($trackId, $descUpdates)) $fields['description'] = $descUpdates[$trackId] ?: null; if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) { $fields['language'] = $languageUpdates[$trackId]; $fields['label'] = strtoupper($languageUpdates[$trackId]); } if ($fields) $video->audioTracks()->where('id', (int) $trackId)->update($fields); } // Replace audio files for existing tracks if ($request->hasFile('track_file_updates')) { foreach ($request->file('track_file_updates') as $trackId => $trackFile) { if (! $trackFile || ! $trackFile->isValid()) continue; $track = $video->audioTracks()->find((int) $trackId); if (! $track) continue; $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3'); if ($nas->isEnabled()) { try { $videoDir = $this->nasVideoDir($video, $nas); $nasDir = "{$videoDir}/tracks"; $nas->mkdirp($nasDir); if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) { try { $nas->deleteFile($track->path); } catch (\Throwable $e) {} } $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempAbs = storage_path('app/' . $tempPath); $nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nas->putFile($tempAbs, $nasPath); @unlink($tempAbs); $track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]); } catch (\Throwable $e) { \Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage()); } } else { $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); } } } // Upload new extra tracks if ($request->hasFile('extra_track_files')) { $trackFiles = $request->file('extra_track_files'); $trackLangs = $request->input('extra_track_languages', []); $trackTitles = $request->input('extra_track_titles', []); foreach ($trackFiles as $i => $trackFile) { if (! $trackFile || ! $trackFile->isValid()) continue; $lang = $trackLangs[$i] ?? 'en'; $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3'); $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null; $track = VideoAudioTrack::create([ 'video_id' => $video->id, 'language' => $lang, 'label' => strtoupper($lang), 'title' => $title, 'path' => '__pending__', 'filename' => '__pending__', ]); if ($nas->isEnabled()) { try { $videoDir = $this->nasVideoDir($video, $nas); $nasDir = "{$videoDir}/tracks"; $nas->mkdirp($nasDir); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempAbs = storage_path('app/' . $tempPath); $nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nas->putFile($tempAbs, $nasPath); @unlink($tempAbs); $track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]); } catch (\Throwable $e) { \Log::error("Extra track NAS upload failed (update): " . $e->getMessage()); $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); } } else { $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); } } } } // Promote a secondary track to primary (swap metadata) if ($promoteId = (int) $request->input('promote_track_id')) { $promoteTrack = $video->audioTracks()->find($promoteId); if ($promoteTrack) { \Log::info('Track promote: swapping primary ↔ secondary', [ 'video_id' => $video->id, 'old_primary' => ['lang' => $video->language, 'path' => $video->path, 'filename' => $video->filename], 'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename], ]); // Create new secondary track from current primary metadata VideoAudioTrack::create([ 'video_id' => $video->id, 'language' => $video->language ?? 'en', 'label' => strtoupper($video->language ?? 'en'), 'title' => $video->title, 'description' => $video->description, 'path' => $video->path, 'filename' => $video->filename, ]); // Override $data with the promoted track's values $data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title); $data['language'] = $promoteTrack->language; $data['description'] = $promoteTrack->description ?: ($data['description'] ?? $video->description); $data['path'] = $promoteTrack->path; $data['filename'] = $promoteTrack->filename; $promoteTrack->delete(); } else { \Log::warning('Track promote: promote_track_id not found', ['video_id' => $video->id, 'promote_track_id' => $promoteId]); } } $video->update($data); // If the title changed, rename the NAS folder and update meta.json. // We do NOT dispatch NasSyncVideoJob for regular edits — thumbnails and // slides are already pushed to NAS above, so there is nothing left to sync. if (($data['title'] ?? $oldTitle) !== $oldTitle) { try { $nas = app(\App\Services\NasSyncService::class); if ($nas->isEnabled()) { $nas->renameVideoDir($video->fresh()); $nas->syncVideoMeta($video->fresh()); } } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('NAS video dir rename/meta 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($video->path); if ($video->thumbnail) { Storage::delete($video->thumbnailStorageKey()); } $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!'); } // ───────────────────────────────────────────────────────────────────────── // Replace media file (keeps all metadata, views, likes, comments intact) // ───────────────────────────────────────────────────────────────────────── // Replace media file (keeps all metadata, views, likes, comments intact) // ───────────────────────────────────────────────────────────────────────── public function replaceFile(Request $request, Video $video) { $user = Auth::user(); if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) { abort(403); } $request->validate([ 'replacement_file' => [ 'required', 'file', 'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus', ], ]); $newFile = $request->file('replacement_file'); $mimeType = $newFile->getMimeType(); $isAudio = str_starts_with($mimeType, 'audio/'); $newSize = $newFile->getSize(); $newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4')); $nas = app(\App\Services\NasSyncService::class); // ── 1. Clear old HLS ───────────────────────────────────────────────── if ($video->has_hls && $video->hls_path) { \Storage::deleteDirectory($video->hls_path); } // ── 2. Delete old media file ───────────────────────────────────────── // NAS: only delete the video file; thumbnail/slides/meta.json stay // Local: unlink the local copy if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) { try { $nas->deleteFile($video->path); } catch (\Throwable) {} } else { $oldLocal = storage_path('app/' . $video->path); if (file_exists($oldLocal)) @unlink($oldLocal); } // ── 3. Store new file to a temporary local path ────────────────────── $tempFilename = \Str::uuid() . '.' . $newExt; $tempRelPath = 'public/videos/' . $tempFilename; $newFile->storeAs('public/videos', $tempFilename); $tempAbsPath = storage_path('app/' . $tempRelPath); if (! file_exists($tempAbsPath)) { return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500); } // ── 4. Extract metadata via FFprobe ────────────────────────────────── $width = $height = null; $orientation = 'landscape'; $duration = 0; try { $ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe'); $out = []; exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out); $duration = (int) round((float) ($out[0] ?? 0)); if (! $isAudio) { $ffprobe = \FFMpeg\FFProbe::create(); $stream = $ffprobe->streams($tempAbsPath)->videos()->first(); if ($stream) { $width = $stream->get('width'); $height = $stream->get('height'); if ($width && $height) { if ($height > $width) $orientation = 'portrait'; elseif ($width > $height) $orientation = 'landscape'; else $orientation = 'square'; } } } } catch (\Throwable $e) { \Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage()); } // ── 5. Persist the file to its final location ───────────────────────── // // NAS path: push directly to NAS using uploadDirectToNas() // This handles legacy paths correctly by computing the // proper users/.../videos/... directory. // uploadDirectToNas() updates path/filename in DB. // // Local path: update path/filename to temp location, then call // organizeLocalFiles() which moves it to users/... layout. // CompressVideoJob() sets status=ready and chains HLS. $nasReplaceSucceeded = false; if ($nas->isEnabled()) { // Point filename at the new file (uploadDirectToNas uses this for ext) $video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]); try { // Pass null for thumb — we don't want to overwrite the existing thumbnail $nas->uploadDirectToNas($video, $tempAbsPath, null); $video->refresh(); $nasReplaceSucceeded = true; } catch (\Throwable $e) { \Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage()); // Temp file still exists — fall through to organizeLocalFiles below $video->update(['path' => $tempRelPath, 'filename' => $tempFilename]); } } if ($nasReplaceSucceeded) { $metaUpdates = [ 'size' => $newSize, 'mime_type'=> $mimeType, 'has_hls' => false, 'hls_path' => null, // For NAS the upload is the "done" state — set ready so GenerateHlsJob runs 'status' => 'ready', ]; if (! $isAudio) { $metaUpdates['duration'] = $duration ?: $video->duration; $metaUpdates['width'] = $width ?: $video->width; $metaUpdates['height'] = $height ?: $video->height; $metaUpdates['orientation'] = $orientation; $metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait'; } $video->update($metaUpdates); if (! $isAudio) { \App\Jobs\GenerateHlsJob::dispatch($video->fresh()) ->onQueue('video-processing') ->onConnection('database'); } } else { // NAS unavailable — keep file locally; NAS-mirrored layout try { $nas->organizeLocalFiles($video); $video->refresh(); } catch (\Throwable $e) { \Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage()); } $metaUpdates = [ 'size' => $newSize, 'mime_type'=> $mimeType, 'has_hls' => false, 'hls_path' => null, 'status' => $isAudio ? 'ready' : 'processing', ]; if (! $isAudio) { $metaUpdates['duration'] = $duration ?: $video->duration; $metaUpdates['width'] = $width ?: $video->width; $metaUpdates['height'] = $height ?: $video->height; $metaUpdates['orientation'] = $orientation; $metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait'; } $video->update($metaUpdates); if (! $isAudio) { \App\Jobs\CompressVideoJob::dispatch($video->fresh()) ->onQueue('video-processing') ->onConnection('database'); } } return response()->json([ 'success' => true, 'message' => $isAudio ? 'Audio file replaced successfully.' : 'File replaced — re-encoding has started. The video will be ready shortly.', 'status' => $video->fresh()->status, 'is_audio' => $isAudio, ]); } 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 = $video->localVideoPath(); // 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: private, 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: private, max-age=3600'); fpassthru($handle); fclose($handle); exit; } } public function streamAudioTrack(Video $video, VideoAudioTrack $track) { if ($track->video_id !== $video->id) { abort(404); } if (! $video->canView(Auth::user())) { abort(404); } $localPath = $track->localPath(); if (! file_exists($localPath)) { // Use the same robust fallback chain as the primary stream(): // local path → nas_cache → NAS download. This is what lets a demoted // secondary track (e.g. the old primary after a language swap) keep // playing even when its file only exists in the stream cache. $nas = app(\App\Services\NasSyncService::class); $resolved = $nas->ensureLocalTrackCopy($track); if (! $resolved) { \Log::warning('streamAudioTrack: file not found', [ 'video_id' => $video->id, 'track_id' => $track->id, 'language' => $track->language, 'path' => $track->path, 'filename' => $track->filename, 'nas_enabled' => $nas->isEnabled(), ]); abort(404, 'Audio track file not found'); } $localPath = $resolved; } $fileSize = filesize($localPath); $ext = strtolower(pathinfo($track->filename, PATHINFO_EXTENSION)); $mimeMap = ['mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac', 'flac' => 'audio/flac', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg']; $mimeType = $mimeMap[$ext] ?? 'audio/mpeg'; $handle = fopen($localPath, 'rb'); if (! $handle) abort(500, 'Cannot open audio track'); $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: private, max-age=3600'); fseek($handle, $start); $remaining = $length; while (! feof($handle) && $remaining > 0) { $buf = fread($handle, min(8192, $remaining)); echo $buf; flush(); $remaining -= strlen($buf); } fclose($handle); exit; } if (request()->boolean('download')) { $downloadName = \Str::slug($video->title) . '-' . strtolower($track->label ?: $track->language) . '.' . $ext; header('Content-Disposition: attachment; filename="' . $downloadName . '"'); } header('Content-Type: ' . $mimeType); header('Content-Length: ' . $fileSize); header('Accept-Ranges: bytes'); header('Cache-Control: private, max-age=3600'); fpassthru($handle); fclose($handle); exit; } public function deleteAudioTrack(Request $request, Video $video, VideoAudioTrack $track) { if ($track->video_id !== $video->id) abort(404); if (Auth::id() !== $video->user_id) abort(403); $nas = app(\App\Services\NasSyncService::class); if ($track->path !== '__pending__') { if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) { try { $nas->deleteFile($track->path); } catch (\Throwable $e) {} } else { @unlink($track->localPath()); } } $track->delete(); return response()->json(['success' => true]); } private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void { try { $localDir = $nas->localVideoDir($video) . '/tracks'; @mkdir($localDir, 0755, true); $trackFile->move($localDir, "{$track->id}.{$ext}"); $userSlug = $nas->userSlug($video->user); $videoBase = basename($nas->localVideoDir($video)); $relPath = "users/{$userSlug}/videos/{$videoBase}/tracks/{$track->id}.{$ext}"; $track->update(['path' => $relPath, 'filename' => "{$track->id}.{$ext}"]); } catch (\Throwable $e) { \Log::error("storeTrackLocally failed: " . $e->getMessage()); } } 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 = $video->localVideoPath(); // 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 = $video->localVideoPath(); 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($s->localPath()))->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 = $slide->localPath(); $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 = $validSlides->first()->localPath(); $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 = $video->localVideoPath(); if (! file_exists($path)) { $nas = app(\App\Services\NasSyncService::class); $path = $nas->ensureLocalCopy($video); if (! $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); } /** * Return the NAS video root directory (where thumb.jpg and slides/ live). * When the primary file was promoted from a secondary track, video->path points * into a 'tracks/' subfolder — in that case we go up one extra level. */ private function nasVideoDir(Video $video, \App\Services\NasSyncService $nas): string { if (str_starts_with($video->path, 'users/')) { $dir = dirname($video->path); if (basename($dir) === 'tracks') { $dir = dirname($dir); } return $dir; } return $nas->resolveVideoDir($video); } 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 = $video->localThumbnailPath(); if ($path && 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 ? route('media.avatar', $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 ? route('media.avatar', $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 ? route('media.avatar', $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 ? route('media.avatar', $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(); // Who liked this video $likers = \DB::table('video_likes') ->join('users', 'users.id', '=', 'video_likes.user_id') ->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at') ->where('video_likes.video_id', $id) ->orderByDesc('video_likes.created_at') ->limit(50) ->get() ->map(fn ($u) => [ 'id' => $u->id, 'channel' => $u->username, 'name' => $u->name, 'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id, 'liked_at' => $u->liked_at, ]); // ── 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(); $user = $s->user_id ? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username']) : null; // Most common country among accesses for this link $topCountry = \DB::table('share_accesses') ->where('share_id', $s->id) ->whereNotNull('country') ->selectRaw('country, country_name, count(*) as cnt') ->groupBy('country', 'country_name') ->orderByDesc('cnt') ->first(); return [ 'token' => $s->token, 'sharer' => $user ? $user->name : 'Guest', 'sharer_id' => $user ? $user->id : null, 'sharer_channel' => $user ? ($user->username ?? $user->id) : null, 'avatar' => $user ? ($user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id) : null, 'country' => $topCountry ? $topCountry->country : null, 'country_name' => $topCountry ? $topCountry->country_name : null, '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, 'likers' => $likers, '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 ? route('media.avatar', $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 ? route('media.avatar', $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 ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id, ], 'total' => $records->count(), 'records' => $records, ]); } }