middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
}
/**
* Cursor-paginated feed slice for infinite scroll.
*
* Called by the sentinel IntersectionObserver on /videos (and single-type
* filters). Returns rendered HTML for the next page plus
* the next cursor, or null when there's nothing left. The first page is
* served by index() as regular HTML, so this endpoint is only ever used
* once an initial grid is already on screen.
*
* Cursor is Laravel's native cursor-pagination token, which is stable
* across inserts (unlike `?page=N` which can skip or duplicate items
* when a new video is uploaded between page fetches).
*/
public function feedMore(Request $request)
{
$filter = (string) $request->query('filter', 'all');
$pageSize = 24;
$query = Video::public()->notShorts();
if (str_starts_with($filter, 'lang:')) {
$lang = strtolower(substr($filter, 5));
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
$secondaryIds = VideoAudioTrack::where('language', $lang)
->whereIn('video_id', Video::public()->pluck('id'))
->pluck('video_id');
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
$query = Video::public()->whereIn('id', $ids);
} elseif ($filter === 'shorts') {
$query = Video::public()->shorts();
} elseif ($filter === 'match') {
$query = Video::public()->where('type', 'match')->notShorts();
} elseif ($filter === 'music') {
$query = Video::public()->where('type', 'music')->notShorts();
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
// Home + Latest both show the newest-first mixed-type feed.
// Playlists intentionally stay on their own tab — this endpoint
// only pages the video grid.
$query = Video::public()->notShorts();
} else {
// Unknown filter — behave like the empty state so a crafted
// ?filter= value can never leak the whole table.
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
}
$paginator = $query->latest('id')->cursorPaginate($pageSize);
$html = '';
foreach ($paginator->items() as $video) {
$html .= view('components.video-card', ['video' => $video])->render();
}
return response()->json([
'items' => $html,
'next_cursor' => optional($paginator->nextCursor())->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
public function index()
{
$filter = request('filter', 'all');
// Chips: distinct languages that actually appear in public content —
// either as a video's primary language or as any of its audio tracks.
// Sorted by video count so the most-used languages come first, capped
// at 12 so the bar doesn't overflow.
$langChips = $this->buildLanguageChips();
// ── Filter by language (?filter=lang:xx) ──────────────────
// Matches when the video's primary language is xx OR when any of
// its secondary audio tracks is xx. When a card is being shown only
// because of a secondary track, we tag it so video-card can link
// straight to that track via ?track=ID and badge it with the flag.
if (str_starts_with((string) $filter, 'lang:')) {
$lang = strtolower(substr($filter, 5));
$primaryIds = \App\Models\Video::public()->where('language', $lang)->pluck('id');
$secondaryTracks = \App\Models\VideoAudioTrack::where('language', $lang)
->whereIn('video_id', \App\Models\Video::public()->pluck('id'))
->get();
$secondaryVideoIds = $secondaryTracks->pluck('video_id')->unique();
// If the video is already primary-matched, no track override.
// Otherwise pin the surfaced track.
$trackByVideo = $secondaryTracks
->filter(fn ($t) => ! $primaryIds->contains($t->video_id))
->keyBy('video_id');
$ids = $primaryIds->concat($secondaryVideoIds)->unique()->values();
$videos = \App\Models\Video::public()->whereIn('id', $ids)
->latest()->limit(60)->get();
foreach ($videos as $v) {
if ($trackByVideo->has($v->id)) {
$v->setAttribute('_force_track_id', $trackByVideo[$v->id]->id);
$v->setAttribute('_force_track_flag', \App\Data\Languages::flag($lang));
}
}
return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── 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', 'langChips') + [
'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', 'langChips') + [
'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', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── Filtered single-type views ────────────────────────────
if (in_array($filter, ['music', 'latest'])) {
// Both tabs sort newest-first. Previously the music branch skipped
// the latest() call entirely, so Eloquent fell back to id ASC and
// the music grid opened on the oldest uploads — never surfacing
// anything new.
$query = Video::public()->notShorts()->latest();
if ($filter === 'music') $query->where('type', 'music');
$videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter', 'langChips') + [
'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', 'langChips') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
/**
* Distinct languages currently present in public content, sorted by how
* many videos each covers (primary + secondary tracks combined). Returned
* as [ ['code'=>'ja','flag'=>'jp','name'=>'Japanese','count'=>N], ... ].
* Pass a $limit only if the caller wants a preview; the bar itself is
* horizontally scrollable so the default is uncapped.
*/
protected function buildLanguageChips(?int $limit = null): array
{
$publicIds = \App\Models\Video::public()->pluck('id');
if ($publicIds->isEmpty()) return [];
// Video-per-language: union of primary language and each track's
// language, deduplicated per video so a video with EN primary + EN
// extra doesn't inflate the EN count.
$primary = \App\Models\Video::public()
->whereNotNull('language')->where('language', '!=', '')
->pluck('language', 'id');
$tracks = \App\Models\VideoAudioTrack::whereIn('video_id', $publicIds)
->whereNotNull('language')->where('language', '!=', '')
->get(['video_id', 'language']);
$langsPerVideo = [];
foreach ($primary as $vid => $lang) {
$langsPerVideo[$vid][strtolower($lang)] = true;
}
foreach ($tracks as $t) {
$langsPerVideo[$t->video_id][strtolower($t->language)] = true;
}
$counts = [];
foreach ($langsPerVideo as $langs) {
foreach (array_keys($langs) as $code) {
$counts[$code] = ($counts[$code] ?? 0) + 1;
}
}
arsort($counts);
$chips = [];
foreach ($counts as $code => $count) {
$flag = \App\Data\Languages::flag($code);
if (! $flag) continue; // skip unknown codes
$name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code);
$chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count];
if ($limit !== null && count($chips) >= $limit) break;
}
return $chips;
}
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');
}
/**
* Receive one chunk of a large upload. Client posts chunks of ~8 MB
* (well under Cloudflare's 100 MB per-request cap) sequentially; server
* appends each to a per-upload buffer under data/app/tmp/uploads/{uid}/.
* When the final `store` call is made with `video_upload_id`, the
* assembled buffer is promoted to the real UploadedFile.
*/
public function uploadChunk(Request $request)
{
$request->validate([
'upload_id' => ['required', 'string', 'regex:/^[A-Za-z0-9\-]{8,64}$/'],
'chunk_index' => 'required|integer|min:0',
'total_chunks' => 'required|integer|min:1|max:100000',
'filename' => 'required|string|max:255',
'chunk' => 'required|file',
]);
$uid = Auth::id();
$uploadId = $request->input('upload_id');
$idx = (int) $request->input('chunk_index');
$total = (int) $request->input('total_chunks');
$filename = basename($request->input('filename'));
$dir = storage_path("app/tmp/uploads/{$uid}");
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
return response()->json(['success' => false, 'message' => 'Cannot create upload buffer'], 500);
}
$partPath = "{$dir}/{$uploadId}.part";
$mode = $idx === 0 ? 'wb' : 'ab';
$out = @fopen($partPath, $mode);
if (!$out) {
return response()->json(['success' => false, 'message' => 'Cannot open upload buffer'], 500);
}
$in = fopen($request->file('chunk')->getRealPath(), 'rb');
while (!feof($in)) fwrite($out, fread($in, 1 << 20));
fclose($in);
fclose($out);
@file_put_contents("{$dir}/{$uploadId}.meta", json_encode([
'filename' => $filename,
'total_chunks' => $total,
'received' => $idx + 1,
'updated_at' => time(),
]));
return response()->json([
'success' => true,
'chunk_index' => $idx,
'received' => $idx + 1,
'total_chunks' => $total,
'complete' => ($idx + 1) === $total,
'size' => filesize($partPath),
]);
}
public function store(Request $request)
{
try {
// Reassemble chunked upload (see uploadChunk). Must run BEFORE any
// call to $request->file()/hasFile() — Laravel caches convertedFiles
// on first access, and a stale cache would ignore our injected file.
// Using $request->files->has() (Symfony FileBag) does not trigger
// that cache, so it's safe to check here.
if ($request->filled('video_upload_id') && !$request->files->has('video')) {
$uid = Auth::id();
$uploadId = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $request->input('video_upload_id'));
$filename = basename((string) $request->input('video_filename', 'upload.mp4'));
$partPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.part");
$metaPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.meta");
if (!$uploadId || !is_file($partPath)) {
return response()->json([
'success' => false,
'message' => 'Upload buffer not found — please retry.',
], 422);
}
$mime = @mime_content_type($partPath) ?: 'application/octet-stream';
$request->files->set('video', new \Illuminate\Http\UploadedFile(
$partPath, $filename, $mime, null, true
));
@unlink($metaPath);
}
$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',
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
// If absent for a given index, the track inherits the primary's slides
// at render time via Video::slidesForTrack().
'extra_track_slides' => 'nullable|array',
'extra_track_slides.*' => 'nullable|array',
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$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' => \App\Support\HtmlSanitizer::clean($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).
// GenerateHlsJob no-ops unless status === 'ready', and on the NAS branch the
// upload IS the "done" state (there's no local CompressVideoJob to flip it).
if (! $isAudioUpload) {
$duration = 0;
try {
$ffprobeBin = config('ffmpeg.ffprobe.binaries', '/usr/bin/ffprobe');
$localCopy = app(\App\Services\NasSyncService::class)->ensureLocalCopy($video->fresh());
if ($localCopy && file_exists($localCopy)) {
$out = [];
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($localCopy), $out);
$duration = (int) round((float) ($out[0] ?? 0));
}
} catch (\Throwable $e) {
\Log::warning('store: ffprobe duration failed: ' . $e->getMessage());
}
$video->update([
'status' => 'ready',
'duration' => $duration ?: $video->duration,
'is_shorts' => ($duration ?: $video->duration) <= 60 && $orientation === 'portrait',
]);
\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', []);
$trackSlides = $request->file('extra_track_slides') ?: [];
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]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : 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 {
// Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
$trackDir = $nas->trackDir($video, $track);
$nas->mkdirp($trackDir);
$canonical = "audio.{$ext}";
$nasPath = "{$trackDir}/{$canonical}";
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => $canonical,
]);
} 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);
}
// ── Optional per-track slides ──────────────────────────────
// The track only owns slides that were uploaded for it. If none
// were uploaded, the player falls back to the primary's at render
// time via Video::slidesForTrack — no row needed here.
$files = $trackSlides[$i] ?? null;
if (is_array($files) && count($files) > 0) {
$this->storeTrackSlides($video, $track, $files, $nas);
}
}
}
// ── Synced lyrics generation (audio/music uploads only) ───────────────
if ($isAudioUpload) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
foreach ($video->audioTracks()->pluck('id') as $tid) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
}
}
$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,
'video_id' => $video->id,
'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);
// Persistent client-side device ID (set in the response cookie below).
// Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
$viewDid = $request->cookie('_did') ?: (string) Str::uuid();
// Device fingerprint hash (set client-side by /fp.js after first paint).
// Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
// Null on the very first visit; the JS will call /identify to backfill it.
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
// Owners don't count toward their own view totals. Previews, re-watches
// from the owner's own account, and even owner-impersonation sessions
// would otherwise inflate the number. The share/analytics screens
// already filter owner_id separately, so skipping the insert is safe.
$isOwnerViewing = Auth::check() && Auth::id() === (int) $video->user_id;
if (Auth::check() && ! $isOwnerViewing) {
// 30-minute user-level dedupe. Also dedupe against the strongest
// device signal available (fingerprint > device_id cookie) so a
// user signing out and back in during the same session doesn't
// double-count.
$exists = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) {
$q->where('user_id', Auth::id());
if ($viewFp) {
$q->orWhere('device_hash', $viewFp);
} elseif ($viewDid) {
$q->orWhere('device_id', $viewDid);
}
})
->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'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
} elseif (! $isOwnerViewing) {
// Guest: 30-minute dedupe keyed on the strongest device signal
// (fingerprint hash > device_id cookie).
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
})
->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'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
}
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);
// Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
// tags and
reflect that language track (so a shared English version shows
// the English title, not the primary). Falls back to the primary when unset.
$shareTitle = $video->title;
$shareDescription = $video->description;
if ($shareTrackId = (int) $request->input('track', 0)) {
$shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
if ($shareTrack) {
if (! empty($shareTrack->title)) $shareTitle = $shareTrack->title;
if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
}
}
$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())) {
// Load the videos ONCE with their owners eager-loaded, then
// compute prev/next in PHP. The old code fired 4+ separate
// queries for prev/next/list — the sidebar lag the user
// reported was almost entirely those extra round-trips.
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
// Count the playlist view (deduped per device, 1-hour window)
// after the response is flushed so we don't pay the round-trip
// on the hot path.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
}
}
// Up Next recommendations: a weighted blend instead of the plain
// chronological "newest 20" the sidebar used to show.
//
// +4 same type (music / match / generic)
// +3 same primary language
// +2 overlapping tag (any match in the comma-separated tags list)
// +1 same channel (keeps a creator's recent uploads surfaced, but
// does not dominate — cross-channel results compete on type/
// language/tags scoring)
//
// Candidates: the newest ~200 public ready videos excluding the one
// being watched. 200 is big enough for the blend to find real
// similarities, small enough to score in-process without a query
// per candidate.
$recommendedVideos = (function () use ($video) {
$candidates = Video::public()
->where('status', 'ready')
->where('id', '!=', $video->id)
->with('user')
->latest('id')
->limit(200)
->get();
$ownTags = collect(explode(',', (string) ($video->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter()
->unique();
$scored = $candidates->map(function (Video $cand) use ($video, $ownTags) {
$score = 0;
if ($cand->type === $video->type) $score += 4;
if ($video->language && $cand->language === $video->language) $score += 3;
if ($ownTags->isNotEmpty()) {
$candTags = collect(explode(',', (string) ($cand->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter();
if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2;
}
if ($cand->user_id === $video->user_id) $score += 1;
$cand->_rec_score = $score;
return $cand;
});
// Sort by score desc, then newest. Keep up to 20.
return $scored
->sortBy([
['_rec_score', 'desc'],
['created_at', 'desc'],
])
->take(20)
->values();
})();
$view = match ($video->type) {
'match' => 'videos.types.match',
'music' => 'videos.types.music',
default => 'videos.types.generic',
};
// Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $viewDid, 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');
// Requested language track (0/absent = primary). Only honoured when
// the id actually belongs to this video.
$requestedTrackId = (int) $request->query('track', 0);
$requestedTrack = $requestedTrackId
? $video->audioTracks->firstWhere('id', $requestedTrackId)
: null;
// Per-track slide map (key "0" = primary). Each entry already has the
// sharing fallback applied by Video::slidesForTrack — a track without its
// own slides borrows the primary's (or a sibling's) automatically.
$slideMap = ['0' => $video->slidesForTrack(null)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
foreach ($video->audioTracks as $_t) {
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
}
$slides = $requestedTrack
? ($slideMap[(string) $requestedTrack->id] ?: $slideMap['0'])
: $slideMap['0'];
$allLangData = \App\Data\Languages::all();
// Dedupe the language picker by (language + label). A video with two
// tracks sharing the same language (e.g. "ar" uploaded twice while
// retrying) used to list both and leave the viewer guessing. Keeping
// the first occurrence preserves the primary when it exists; using
// (lang, label) as the key still lets legit variants coexist (e.g.
// "en — Original" and "en — Director's cut").
$audioTracks = $video->audioTracks
->unique(fn ($t) => mb_strtolower((string) $t->language) . '|' . mb_strtolower((string) ($t->label ?? '')))
->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();
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
// Local mirror only — must not block this hot path on NAS I/O.
$nasLyrics = app(\App\Services\NasSyncService::class);
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
foreach ($video->audioTracks as $t) {
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
}
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' => 'playlist.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
// When a specific language track is requested (playlist row pin,
// shared link with ?track=), expose it separately. The player uses
// this for the actual src while stream_url stays as the
// primary, so the language popup still lists the true primary.
'active_track_id' => $requestedTrack->id ?? null,
'active_stream_url' => $requestedTrack
? route('videos.audio-track', ['video' => $video, 'track' => $requestedTrack->id])
. '?v=' . $requestedTrack->updated_at->timestamp
: null,
'cover_url' => $coverUrl,
'slides' => $slides,
'slide_map' => $slideMap,
'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,
'lyrics' => $lyricsMap,
]);
}
/**
* Owner-triggered lyrics generation for the current audio track (?track={id},
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
* polls player-data and shows the lyrics once the file lands.
*/
public function generateLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
}
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
}
$trackId = (int) $request->input('track', 0);
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
return response()->json(['status' => 'queued']);
}
/**
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$html = (string) $video->description;
if (trim($html) === '') {
return response()->json(['error' => 'Nothing to translate'], 400);
}
// Flatten the rich description → plain text, keeping paragraph breaks
// so the client can render original and translation line-by-line.
$plain = trim(html_entity_decode(strip_tags(
preg_replace(['/<\/p>/i', '/ /i'], ["\n", "\n"], $html)
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$lines = preg_split('/\R/u', $plain);
$target = (string) $request->input('target', 'en');
// Source language inferred from the owner's access-history country,
// falling back to users.nationality.
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
$sourceCountry = $topCountries[$video->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
if (! $translatedLines || count($translatedLines) !== count($lines)) {
return response()->json(['error' => 'Translation failed'], 502);
}
$segments = [];
foreach ($lines as $i => $src) {
$segments[] = [
'original' => (string) $src,
'translated' => (string) ($translatedLines[$i] ?? ''),
];
}
return response()->json([
'success' => true,
'segments' => $segments,
'target' => $target,
'source_hint' => $sourceLang,
'author_country' => $sourceCountry,
]);
}
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
// Clear any in-flight progress / temp artifacts so a queued job that
// fires later can't repopulate stale output.
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
return response()->json(['status' => 'deleted']);
}
/**
* Live progress for an in-flight lyrics generation, driving the player's
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
*/
public function lyricsProgress(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['status' => 'none']);
}
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
if (is_array($data)) {
$st = $data['status'] ?? 'ready';
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
if ($st === 'failed') return response()->json(['status' => 'failed']);
}
// Live percentage from the pipeline's progress file.
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
if (is_file($progFile)) {
$p = json_decode((string) file_get_contents($progFile), true);
if (is_array($p)) {
return response()->json([
'status' => 'processing',
'pct' => (int) ($p['pct'] ?? 1),
'stage' => $p['stage'] ?? 'Working',
]);
}
}
// A 'processing' lyrics marker but no progress file yet → just queued.
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
return response()->json(['status' => 'none']);
}
/**
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
* track; preserves precise word timing for lines that weren't changed and
* redistributes timing evenly across the new words for edited lines.
*/
public function saveLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$existing = $nas->getLocalLyrics($video, $track) ?: [];
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
$inLines = $request->input('lines', []);
if (! is_array($inLines)) $inLines = [];
$out = [];
foreach ($inLines as $ln) {
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
if ($text === '' || mb_strlen($text) > 1000) {
if ($text === '') continue;
$text = mb_substr($text, 0, 1000);
}
$start = round((float) ($ln['start'] ?? 0), 3);
$end = round((float) ($ln['end'] ?? $start), 3);
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
$isSpaceless = in_array($lang, $spaceless, true);
// Keep original word timings if the text wasn't changed; otherwise
// redistribute the line's span evenly across the new tokens.
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
$sep = $isSpaceless ? '' : ' ';
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
$unchanged = $origWords
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
if ($unchanged) {
$words = array_map(fn ($w) => [
'start' => round((float) ($w['start'] ?? $start), 3),
'end' => round((float) ($w['end'] ?? $end), 3),
'text' => (string) ($w['text'] ?? ''),
], $origWords);
} else {
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
}
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
if (count($out) >= 1000) break;
}
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'ready',
'source' => 'edited',
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
'multilingual' => $existing['multilingual'] ?? false,
'lines' => $out,
'generated_at' => now()->toIso8601String(),
]);
return response()->json(['status' => 'ok', 'lines' => count($out)]);
}
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
{
$text = trim($text);
if ($text === '' || $end <= $start) {
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
}
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
$n = count($tokens);
if ($n === 0) return [];
$slice = ($end - $start) / $n;
$words = [];
foreach ($tokens as $i => $tok) {
$words[] = [
'start' => round($start + $i * $slice, 3),
'end' => round($start + ($i + 1) * $slice, 3),
'text' => $tok,
];
}
return $words;
}
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,
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
])->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,
'sports_match_id'=> $video->type === 'match'
? \App\Models\SportsMatch::where('video_id', $video->id)->value('id')
: null,
],
]);
}
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',
// Ordered list of track IDs as they appear in the edit UI. Index
// 0 is the primary; the value at each index is either a numeric
// track id (existing secondary) or the literal string "primary"
// for the original primary. Missing = no reorder was performed.
'track_order' => 'nullable|array',
'track_order.*' => 'nullable|string|max:32',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
if (array_key_exists('description', $data)) {
$data['description'] = \App\Support\HtmlSanitizer::clean($data['description']);
}
$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, mirroring the NAS path
$nasDir = $this->nasVideoDir($video, $nas);
$localDir = storage_path('app/' . $nasDir);
@mkdir($localDir, 0755, true);
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
}
if (! isset($data['visibility'])) {
unset($data['visibility']);
}
// ── Optional main media file replacement + type conversion ──────────
// When the edit form ships a new `video` file, replace the primary
// media. If the new file is audio, force type=music. When the type
// folder changes (generic → music, etc.), relocate the file on NAS
// into the canonical per-type layout.
if ($request->hasFile('video')) {
$request->validate([
'video' => 'file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
]);
$newFile = $request->file('video');
$mimeType = $newFile->getMimeType();
$isAudio = str_starts_with((string) $mimeType, 'audio/');
$newSize = $newFile->getSize();
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class);
// Desired type — form value wins, but audio always forces music.
$desiredType = $data['type'] ?? $video->type ?? 'generic';
if ($isAudio) $desiredType = 'music';
$data['type'] = $desiredType;
$oldPath = (string) $video->path;
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
: null;
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
// We must compare against this (not the DB type) because a prior type
// edit can leave a video with type=music but files still under videos/.
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
// Mutate in-memory so typeFolder / resolveVideoDir react to the new type
$video->type = $desiredType;
// For music, the primary track folder is {lang}-{id} — pick up any
// language the same form is about to save so the folder is right.
if (array_key_exists('language', $data)) {
$video->language = $data['language'];
}
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
// Clear old HLS (bytes + DB flags reset below)
if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path);
}
$localHlsAbs = storage_path('app/public/hls/' . $video->id);
if (is_dir($localHlsAbs)) @exec('rm -rf ' . escapeshellarg($localHlsAbs));
// Stash the uploaded file to a temp location
$tempFilename = \Str::uuid() . '.' . $newExt;
$newFile->storeAs('public/tmp', $tempFilename);
$tempAbs = storage_path('app/public/tmp/' . $tempFilename);
if (! file_exists($tempAbs)) {
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
}
// ── 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($tempAbs), $out);
$duration = (int) round((float) ($out[0] ?? 0));
if (! $isAudio) {
$ffprobe = \FFMpeg\FFProbe::create();
$stream = $ffprobe->streams($tempAbs)->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('update: FFprobe on replacement file failed: ' . $e->getMessage());
}
// Prep video for uploadDirectToNas — filename drives ext detection,
// clearing path when relocating forces a fresh dir under the new type.
$video->filename = 'placeholder.' . $newExt;
if ($needsRelocate) $video->path = null;
$nasReplaceSucceeded = false;
if ($nas->isEnabled()) {
try {
$nas->uploadDirectToNas($video, $tempAbs, null);
$video->refresh();
// Re-apply the in-memory type mutation lost by refresh()
$video->type = $desiredType;
$nasReplaceSucceeded = true;
} catch (\Throwable $e) {
\Log::error('update: NAS media replace failed: ' . $e->getMessage());
// Fall through — file stays in /tmp; DB state below still lands
$video->update([
'path' => 'public/tmp/' . $tempFilename,
'filename' => $tempFilename,
]);
}
} else {
$video->update([
'path' => 'public/tmp/' . $tempFilename,
'filename' => $tempFilename,
]);
}
// Cleanup: delete OLD primary file + meta.json from NAS (only when
// it's on NAS AND the new path is different). Leave the old thumbnail
// in place so it keeps serving until the user uploads a new one.
if ($nasReplaceSucceeded && $oldPath && str_starts_with($oldPath, 'users/') && $oldPath !== $video->path) {
try { $nas->deleteFile($oldPath); } catch (\Throwable $e) {}
if ($needsRelocate && $oldVideoDirNas) {
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
if ($oldVideoDirNas !== $newVideoDirNas) {
// Old meta.json must go so resolveVideoDir doesn't get
// confused by two folders claiming the same video id.
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
}
}
}
// Wipe local cache from the OLD dir (regenerable content only)
if ($oldVideoDirNas) {
$oldLocalCache = storage_path('app/' . $oldVideoDirNas . '/cache');
if (is_dir($oldLocalCache)) @exec('rm -rf ' . escapeshellarg($oldLocalCache));
}
// Bake metadata into $data so the trailing $video->update($data) applies it
$data['size'] = $newSize;
$data['mime_type'] = $mimeType;
$data['has_hls'] = false;
$data['hls_path'] = null;
$data['status'] = 'ready';
$data['slideshow_video_path'] = null;
if (! $isAudio) {
$data['duration'] = $duration ?: $video->duration;
$data['width'] = $width ?: $video->width;
$data['height'] = $height ?: $video->height;
$data['orientation'] = $orientation;
$data['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
} else {
$data['duration'] = $duration ?: $video->duration;
$data['width'] = null;
$data['height'] = null;
$data['orientation'] = 'landscape';
$data['is_shorts'] = false;
}
if (! $isAudio) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
}
// Handle slide reorder / removal / additions for audio tracks
// Gate on declared type, not the file extension — a video row can be
// type=music while its primary file is still an mp4 (mid-conversion).
$slidesChanged = false;
if (($data['type'] ?? $video->type) === 'music') {
// 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 — works regardless of NAS state
$relBase = $this->nasVideoDir($video, $nasForSlides);
$nasDir = $nasEnabled ? $relBase : null;
// Local dir mirrors the NAS path so MediaController can find files
// whether NAS is reachable or not.
$localDir = storage_path('app/' . $relBase);
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 videos (plain + visualizer) whenever slides change
if ($slidesChanged) {
// Slides feed every rendered variant (all tracks + visualizer) — wipe them all.
$cacheDir = dirname(storage_path('app/' . $this->slideshowRel($video, false, 0)));
foreach (glob($cacheDir . '/video*.mp4') ?: [] as $f) @unlink($f);
$data['slideshow_video_path'] = null;
}
}
// ── Audio track management (delete + add) ────────────────────────────
// Gate on declared type, not extension (see slide gate above).
if (($data['type'] ?? $video->type) === 'music') {
$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'] = \App\Support\HtmlSanitizer::clean($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);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
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 = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} 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', []);
$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]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} 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)
$promoteId = (int) $request->input('promote_track_id');
$newSecondaryId = null; // set below if a promote happens
if ($promoteId) {
$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
$newSecondary = 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,
]);
$newSecondaryId = $newSecondary->id;
// 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]);
}
}
// Persist the user's reordered track list. `track_order` is the
// ordered list of items in the edit UI, index 0 being the primary.
// Each entry is either the literal string "primary" (the original
// primary track row placeholder) or a numeric secondary-track id.
$trackOrder = $request->input('track_order', []);
if (is_array($trackOrder) && count($trackOrder) > 0) {
foreach ($trackOrder as $i => $val) {
if ($i === 0) continue; // slot 0 is the primary, not a track row
if ($val === 'primary') {
// The old primary was demoted to secondary in the promote
// block above; write its new position.
if ($newSecondaryId) {
VideoAudioTrack::where('id', $newSecondaryId)->update(['position' => $i]);
}
} else {
$tid = (int) $val;
if ($tid <= 0 || $tid === $promoteId) continue; // promoted id is now the primary
VideoAudioTrack::where('id', $tid)
->where('video_id', $video->id)
->update(['position' => $i]);
}
}
}
// Any tracks that were freshly created in this same request (e.g.
// brand-new secondaries uploaded via extra_track_files) still sit at
// position=0 because the client couldn't know their DB id in advance.
// Append them to the end of the list so the ordering stays stable.
$maxPos = (int) $video->audioTracks()->max('position');
foreach ($video->audioTracks()->where('position', 0)->orderBy('id')->get() as $t) {
$maxPos++;
$t->update(['position' => $maxPos]);
}
$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);
// When the replacement is audio, the video must become type=music so the
// NAS layout / player pipeline agree with the file. Force it and relocate.
$desiredType = $isAudio ? 'music' : $video->type;
$oldPath = (string) $video->path;
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
: null;
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
// Compare against this (not DB type) so a stale type edit can still relocate.
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
$video->type = $desiredType;
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
// ── 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)
$update = ['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize, 'type' => $desiredType];
if ($needsRelocate) $update['path'] = null; // force resolveVideoDir to pick a fresh type-appropriate dir
$video->update($update);
try {
// Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh();
$nasReplaceSucceeded = true;
// Clean up the old NAS folder's meta.json if we relocated (so
// resolveVideoDir isn't confused by two folders claiming this id).
if ($needsRelocate && $oldVideoDirNas) {
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
if ($oldVideoDirNas !== $newVideoDirNas) {
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
}
}
} 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)
{
// Snap the ?hours param to one of the three windows the UI exposes
// (Today / This Week / This Month). The old clamp (24..168) silently
// capped the "month" tab at a week, which is why the tabs all showed
// the same data; now 720 is a first-class option.
$hours = (int) $request->get('hours', 24);
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
$limit = min(max((int) $request->get('limit', 50), 10), 100);
// Candidate pool: public ready videos created at any time (not just
// the last 10 days) — a video uploaded three weeks ago still belongs
// in "This Month" if it caught fire this week. We rank by the number
// of views INSIDE the window, with the recency bonus/velocity now
// used only as tiebreakers.
$windowStart = now()->subHours($hours);
$videos = Video::public()
->where('status', 'ready')
->where('created_at', '>=', now()->subMonths(6))
->with('user')
->get();
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
$recentViews = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', $windowStart)
->count();
$likeCount = \DB::table('video_likes')
->where('video_id', $video->id)
->count();
$video->view_count = $recentViews;
$video->like_count = $likeCount;
$video->_tiebreak_age = $video->created_at->timestamp;
return $video;
});
// Rank: in-window views (primary), likes (tiebreak #1), newest
// created_at (tiebreak #2). Any video with zero views in the window
// is dropped so the list never surfaces truly dormant content.
$trendingVideos = $videos
->filter(fn ($v) => $v->view_count > 0)
->sortBy([
['view_count', 'desc'],
['like_count', 'desc'],
['_tiebreak_age', 'desc'],
])
->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 {
// Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
// (storeTrackLocally is only reached for music since extra tracks
// only exist on music videos.)
$songLocalDir = $nas->localVideoDir($video);
$trackFolder = $nas->trackFolderName($video, $track);
$trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
@mkdir($trackDirAbs, 0755, true);
$canonical = "audio.{$ext}";
$trackFile->move($trackDirAbs, $canonical);
// Build the NAS-relative path from the song's relative path so it
// works on both local-only and NAS-enabled setups.
$songRel = $this->relFromStoragePath($songLocalDir);
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
$track->update(['path' => $relPath, 'filename' => $canonical]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
/** Convert an absolute storage_path() value back to a storage-relative path. */
private function relFromStoragePath(string $abs): string
{
$prefix = storage_path('app/');
if (str_starts_with($abs, $prefix)) {
return substr($abs, strlen($prefix));
}
return $abs;
}
/**
* Persist per-track slides — the new track owns them via audio_track_id.
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
* slides/track-{trackId}-{position}.{ext}
* Primary slides keep the legacy slides/{position}.{ext} scheme.
*/
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
{
// Per-track slides live in the track's own folder:
// tracks/{lang-id}/slides/{position}.{ext}
// Filenames are canonical (just {position}.{ext}) because the folder
// already disambiguates by track.
$nasEnabled = $nas->isEnabled();
$songLocal = $nas->localVideoDir($video);
$songRel = $this->relFromStoragePath($songLocal);
$trackFold = $nas->trackFolderName($video, $track);
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
if ($nasEnabled) {
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
} else {
@mkdir("{$localTrackDir}/slides", 0755, true);
}
foreach ($files as $pos => $file) {
if (! $file || ! $file->isValid()) continue;
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$name = "{$pos}.{$ext}";
$slide = VideoSlide::create([
'video_id' => $video->id,
'audio_track_id' => $track->id,
'filename' => '__pending__',
'position' => $pos,
]);
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasEnabled) {
$nasPath = "{$nasTrackDir}/slides/{$name}";
if ($nas->putFile($tempAbs, $nasPath)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasPath]);
continue;
}
// Fall through to local on NAS failure
}
@mkdir("{$localTrackDir}/slides", 0755, true);
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
}
}
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
* overwrite each other even when they share a language.
*/
private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
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/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
$lyrics = request()->boolean('lyrics');
// Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
if ($viz || $trackId || $lyrics) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// Not generated yet — let the player trigger background generation.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
// Primary plain — DB column must confirm it's the current version.
if ($video->slideshow_video_path) {
$slideshowCache = storage_path('app/' . $video->slideshow_video_path);
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);
}
/**
* Extract up to three dominant, saturated colours from a cover image — the same
* algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
* so the baked-in download bars match what viewers see on the page. Returns three
* lowercase hex strings ordered left → middle → right of the bar gradient; falls
* back to light greys when nothing usable is found.
*/
private function slideVisualizerColors(?string $imagePath): array
{
$default = ['ffffff', 'c8c8c8', 'aaaaaa'];
if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
return $default;
}
$raw = @file_get_contents($imagePath);
$img = $raw ? @imagecreatefromstring($raw) : false;
if (! $img) return $default;
$t = imagecreatetruecolor(24, 24);
imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
imagedestroy($img);
$buckets = [];
for ($y = 0; $y < 24; $y++) {
for ($x = 0; $x < 24; $x++) {
$p = imagecolorat($t, $x, $y);
$r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
$bright = ($r + $g + $b) / 3;
if ($bright < 25 || $bright > 230) continue; // skip near-black / near-white
$mx = max($r, $g, $b); $mn = min($r, $g, $b);
if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue; // skip low-saturation
$k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
$buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
}
}
imagedestroy($t);
if (! $buckets) return $default;
usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
$avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];
// Most-common colour first, then the next colours that are visually distinct from it.
$chosen = [$avg($buckets[0])];
for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
$c = $avg($buckets[$i]); $far = true;
foreach ($chosen as $e) {
if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
}
if ($far) $chosen[] = $c;
}
while (count($chosen) < 3) $chosen[] = $chosen[0];
return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
}
// ── Background slideshow generation + progress polling ────────
public function slideshowGenerate(Request $request, Video $video)
{
$this->checkDownloadAccess($video);
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Not an audio file'], 422);
}
// Version-aware: render the video from the audio the viewer is playing — the
// primary track, or a specific language track (?track={id}). The optional
// visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$lyrics = $request->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
// Audio source = the version being played.
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$audioPath = $track->localPath();
if (! file_exists($audioPath) && $nas->isEnabled()) {
$audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
}
} else {
$audioPath = $video->localVideoPath();
}
if (! file_exists($audioPath)) {
return response()->json(['error' => 'Audio file not found'], 404);
}
$outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . $suffix . '.txt';
// Already cached. Only the primary plain video is tracked by the DB column; every
// other variant (a language track and/or visualizer) is guarded by file existence.
// All are invalidated together when the slides are edited (see update()).
$usesDbColumn = (! $viz && ! $trackId);
$cached = $usesDbColumn
? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
: (file_exists($outPath) && filesize($outPath) > 0);
if ($cached) {
return response()->json(['status' => 'ready']);
}
// Stale file on disk (e.g. slides were edited) — delete it before regenerating
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);
}
// Use the slides for the track being rendered, applying the per-track
// sharing fallback (own → primary → sibling). Ensures the download .mp4
// matches the slideshow the listener saw in the player.
$video->loadMissing('slides');
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
$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';
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
// track and weave it into the final video output. $voutLabel/$assArg/
// $assTail let each render branch inject the burn uniformly.
$voutLabel = '[vout]';
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
if ($lyrics) {
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
$assFilter = 'ass=' . $assPath;
$assArg = ',' . $assFilter;
$assTail = ';[vout]' . $assFilter . '[vsub]';
$voutLabel = '[vsub]';
}
}
}
@unlink($progressFile);
@unlink($pidFile);
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
// A single slide is normally a still image, but with the visualizer overlay the
// bars animate — so it must not be encoded with -tune stillimage.
$isStillImage = ($validSlides->count() === 1) && ! $viz;
$vFlags = $this->ffmpegVideoFlags($isStillImage);
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
if ($viz) {
// ── Visualizer build ─────────────────────────────────────────────────
// Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
// multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
// this run below real-time; the loop filter makes it ~25x real-time instead.
// overlay shortest=1 bounds the otherwise-endless looped background to the audio.
$rate = 20;
$inputs = '';
$fcParts = [];
if ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
foreach ($validSlides as $i => $slide) {
$inputs .= ' -i ' . escapeshellarg($slide->localPath());
// fps MUST come last so each branch is constant-frame-rate for xfade.
$fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
. number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = $n;
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
$fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
} elseif ($validSlides->count() === 1) {
$inputs .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
} else {
$inputs .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]format=yuv420p[vbase]";
}
// Frequency bars matched to the in-player visualizer:
// • equal-width bars → fscale=lin (log made the left bars wide, right thin)
// • cover colours → render white bars (a shape+alpha mask), then tint
// them with a horizontal gradient of the cover's 3 dominant colours
// (left → middle → right), exactly like the page's canvas bars
// • translucent overlay → showfreqs already outputs a transparent backdrop,
// so we just dial the bar alpha down and composite over the artwork.
[$c0, $c1, $c2] = $this->slideVisualizerColors(
$validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
);
$fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
. ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
$fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
. ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
$fcParts[] = "[bars]alphaextract[vmask]";
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($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;
}
$fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map ' . $voutLabel . ' -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 . $assArg)
. ' ' . $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'
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
}
$cmd .= ' -progress ' . escapeshellarg($progressFile)
. ' ' . escapeshellarg($outPath);
// $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
// GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
// on CPU (libx264) and the download keeps working.
if (Setting::gpuUsable() && $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)
{
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
$lyrics = request()->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.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) {
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
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);
}
/**
* Song-folder-relative path (under storage/app) for the generated "Download Video".
* Regenerable renders live in the song's own `cache/` subfolder — separated from the
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '')
. ($lyrics ? '-lyr' : '') . '.mp4';
}
/**
* 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);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('video_downloads')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'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)]);
}
/**
* Email a friend a properly-formatted share email, version-aware: the link and the
* email's title reflect the language track the sender chose. The URL is built
* server-side so outgoing mail can never carry an attacker-supplied link.
*/
public function shareByEmail(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'email' => 'required|email|max:255',
'message' => 'nullable|string|max:500',
'track' => 'nullable|integer',
]);
$trackId = (int) ($data['track'] ?? 0);
$shareTitle = $video->title;
$shareUrl = $video->share_url;
if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) {
if (! empty($track->title)) $shareTitle = $track->title;
$shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId;
}
try {
\Mail::to($data['email'])->send(new \App\Mail\VideoShared(
$video,
$shareUrl,
Auth::user(),
$data['message'] ?? null,
$shareTitle,
));
} catch (\Throwable $e) {
\Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]);
return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500);
}
return response()->json(['success' => true]);
}
/**
* Share a video directly with selected members. Each recipient gets an in-app
* notification (clicking it opens the video) and an email.
*/
public function shareWithMembers(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'user_ids' => 'required|array|min:1|max:30',
'user_ids.*' => 'integer|exists:users,id',
'message' => 'nullable|string|max:500',
]);
$sharer = Auth::user();
$message = $data['message'] ?? null;
$recipients = \App\Models\User::whereIn('id', $data['user_ids'])
->where('id', '!=', $sharer->id)
->get();
foreach ($recipients as $member) {
try {
$member->notify(new \App\Notifications\VideoSharedWithUser(
$video, $sharer, $message, $video->share_url
));
} catch (\Throwable $e) {
\Log::error('Share-to-member failed: ' . $e->getMessage(), [
'video_id' => $video->id, 'member_id' => $member->id,
]);
}
}
return response()->json(['success' => true, 'count' => $recipients->count()]);
}
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);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\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,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'accessed_at' => now(),
]);
}
// Carry the version selector (?track=) through the redirect so the recipient opens
// the exact language the sharer was listening to.
$dest = route('videos.showByToken', $video->share_token);
if ($request->filled('track')) {
$dest .= '?track=' . (int) $request->input('track');
}
// Social scrapers (WhatsApp, Facebook, Twitter, LinkedIn, Telegram,
// Discord, Slack) frequently DO NOT follow 302 redirects when
// building link previews — they read og:* tags from the response
// body of the URL they were given. Serving them a redirect leaves
// them with no title/image, which is what the user hit today. For
// those user agents, render the target video page directly so the
// OG tags (title, description, VS card image) are in the response.
// Real users still get the 302 so cookies + tracking behave the same.
$ua = (string) $request->userAgent();
$isScraper = (bool) preg_match(
'#(whatsapp|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|telegrambot|discordbot|skypeuripreview|pinterest|redditbot|embedly|vkshare|w3c_validator|bingbot|googlebot|applebot|yandex|duckduckbot|iframely|nuzzel|quora link preview|outbrain)#i',
$ua
);
if ($isScraper) {
return $this->show($request, $video);
}
return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
/**
* Standalone HTML page that renders the sports-match VS composition
* at 1200×630. Called only by Browsershot from ogImage() below to
* generate the social-share preview PNG for match videos.
*/
public function vsPreview(Video $video)
{
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
// Force http scheme for URLs generated on this page (asset(), route(),
// media thumbnail routes) — the ogImage renderer proxies Chromium at
// this route via loopback (http://127.0.0.1:80), so the app's global
// URL::forceScheme('https') would produce URLs Chromium can't reach.
\Illuminate\Support\Facades\URL::forceScheme('http');
return response()
->view('videos.partials.match.vs-og', ['video' => $video])
->header('X-Robots-Tag', 'noindex, nofollow');
}
public function ogImage(Video $video)
{
// Sports matches get a rendered VS card — the actual render is done
// by a CLI artisan command (Chromium can't launch under PHP-FPM
// reliably), fired detached on cache miss + prefetched on match
// save. If the cached PNG exists, serve it. Otherwise return the
// thumbnail fallback below and let the background job produce the
// PNG for future requests.
if ($video->type === 'match' && $video->sportsMatch) {
$cached = $this->cachedMatchOgPath($video);
if (is_file($cached)) {
return response()->file($cached, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
$this->dispatchMatchOgRender($video);
// Fall through to thumbnail path while the render runs.
}
// 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 trackProgress(Request $request, Video $video)
{
$seconds = max(0, (int) $request->input('watched_seconds', 0));
$completed = (bool) $request->boolean('completed');
$viewDid = $request->cookie('_did');
$query = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDay())
->orderByDesc('id')
->limit(1);
if (Auth::check()) {
$query->where('user_id', Auth::id());
} else {
$query->whereNull('user_id');
if ($viewDid) {
$query->where('device_id', $viewDid);
} else {
return response()->json(['ok' => false], 204);
}
}
$view = $query->first();
if (! $view) {
return response()->json(['ok' => false], 204);
}
$updates = [];
if ($seconds > (int) $view->watched_seconds) {
$updates['watched_seconds'] = $seconds;
}
if ($completed && ! $view->completed) {
$updates['completed'] = true;
}
if ($updates) {
\DB::table('video_views')->where('id', $view->id)->update($updates);
}
return response()->json(['ok' => true]);
}
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 — segmented by viewer category (male / female / other-or-guest)
$rawDaily = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("date(video_views.watched_at) as day,
sum(case when users.gender = 'male' then 1 else 0 end) as male_cnt,
sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
count(*) as cnt")
->where('video_views.video_id', $id)
->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(video_views.watched_at)")
->orderBy('day')
->get()
->keyBy('day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$key = $d->format('Y-m-d');
$row = $rawDaily->get($key);
$daily[] = [
'date' => $key,
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => $row ? (int) $row->cnt : 0,
'male' => $row ? (int) $row->male_cnt : 0,
'female' => $row ? (int) $row->female_cnt : 0,
'other' => $row ? (int) $row->other_cnt : 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 ?: \App\Data\Countries::name($c->country),
'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) — also surface device/browser of their latest visit
$topViewersRaw = \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();
// One extra query: most-recent user_agent per top viewer (small, indexed lookup)
$topViewerIds = $topViewersRaw->pluck('id')->all();
$latestUaByUid = [];
if ($topViewerIds) {
$latestRows = \DB::table('video_views as v1')
->whereIn('v1.user_id', $topViewerIds)
->where('v1.video_id', $id)
->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
->get(['v1.user_id', 'v1.user_agent']);
foreach ($latestRows as $row) {
$latestUaByUid[$row->user_id] = $row->user_agent;
}
}
$topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
$ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: asset('images/default-avatar.svg'),
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$guestViews = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent activity — grouped by viewer.
// Registered users group by user_id. For guests we layer three signals,
// strongest first: device_hash (multi-signal browser fingerprint) → device_id
// cookie → ip_address (legacy / cookie-less). This survives cookie clears,
// incognito mode, browser swaps, and VPN / country hops.
$recentRaw = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select(
'video_views.watched_at',
'video_views.country',
'video_views.ip_address',
'video_views.device_id',
'video_views.device_hash',
'video_views.user_agent',
'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(500)
->get();
$groups = [];
foreach ($recentRaw as $r) {
if ($r->user_id) {
$key = 'u:' . $r->user_id;
} else {
$guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
$key = 'g:' . $guestKey;
}
if (! isset($groups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$groups[$key] = [
'key' => $key,
'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)
: asset('images/default-avatar.svg'))
: null,
'country' => $r->country,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->watched_at,
];
}
$groups[$key]['count']++;
// first row is the most recent (orderByDesc) so device/browser reflect their latest visit
}
$recentViewers = array_values($groups);
// 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) + device/browser of their latest download
$dlUsersRaw = \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();
$dlUserIds = $dlUsersRaw->pluck('id')->all();
$dlLatestUaByUid = [];
if ($dlUserIds) {
$latestDl = \DB::table('video_downloads as d1')
->whereIn('d1.user_id', $dlUserIds)
->where('d1.video_id', $id)
->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
->get(['d1.user_id', 'd1.user_agent']);
foreach ($latestDl as $row) {
$dlLatestUaByUid[$row->user_id] = $row->user_agent;
}
}
$dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
$ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: asset('images/default-avatar.svg'),
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$dlGuests = \DB::table('video_downloads')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent download activity — grouped by downloader.
// Guests are keyed by ip_address (video_downloads doesn't track device_id);
// device/browser comes from the most-recent download's user_agent.
$dlRecentRaw = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select(
'video_downloads.type',
'video_downloads.downloaded_at',
'video_downloads.country',
'video_downloads.country_name',
'video_downloads.ip_address',
'video_downloads.user_agent',
'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(500)
->get();
$dlGroups = [];
foreach ($dlRecentRaw as $r) {
$key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
if (! isset($dlGroups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$dlGroups[$key] = [
'key' => $key,
'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)
: asset('images/default-avatar.svg'))
: null,
'country' => $r->country,
'type' => $r->type,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->downloaded_at,
];
}
$dlGroups[$key]['count']++;
}
$dlRecent = array_values($dlGroups);
// 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, users.gender as gender")
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday')
->get();
$blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
$ageBuckets = [
'Under 13' => $blank, '13–17' => $blank, '18–24' => $blank, '25–34' => $blank,
'35–44' => $blank, '45–54' => $blank, '55–64' => $blank, '65+' => $blank,
];
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]['count']++;
if ($row->gender === 'male') $ageBuckets[$bucket]['male']++;
elseif ($row->gender === 'female') $ageBuckets[$bucket]['female']++;
else $ageBuckets[$bucket]['other']++;
}
$totalAge = array_sum(array_column($ageBuckets, 'count'));
$ageGroups = collect($ageBuckets)
->filter(fn ($b) => $b['count'] > 0)
->map(fn ($b, $label) => [
'label' => $label,
'count' => $b['count'],
'male' => $b['male'],
'female' => $b['female'],
'other' => $b['other'],
'pct' => $totalAge > 0 ? round($b['count'] / $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)
: asset('images/default-avatar.svg'),
'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)
: asset('images/default-avatar.svg'))
: null,
'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null,
'reach' => $accesses,
'created_at' => $s->created_at,
];
})->sortByDesc('reach')->values()->take(10);
// ── Skip rate ──────────────────────────────────────────────
// Skipped = watched_seconds < max(10, 10% of duration)
$duration = (int) ($video->duration ?? 0);
$skipThreshold = max(10, (int) floor($duration * 0.10));
$skippedViews = \DB::table('video_views')
->where('video_id', $id)
->where('watched_seconds', '<', $skipThreshold)
->count();
$skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;
// ── Save rate ──────────────────────────────────────────────
// Distinct users who added this video to a playlist (excluding the uploader's own playlists)
$saveCount = \DB::table('playlist_videos')
->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
->where('playlist_videos.video_id', $id)
->where('playlists.user_id', '!=', $video->user_id)
->distinct('playlists.user_id')
->count('playlists.user_id');
$uniqueViewersAll = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;
// ── Profile (wall) visits originating from this video ───────
$profileVisits = \DB::table('profile_visits')
->where('profile_user_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── New subscribers driven by this video ───────────────────
$newSubscribers = \DB::table('user_subscriptions')
->where('channel_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── Comments (including replies) ───────────────────────────
$commentsCount = \DB::table('comments')->where('video_id', $id)->count();
// ── Accounts reached (distinct users + distinct guest devices) ──
$reachedUsers = $uniqueViewersAll;
$reachedGuests = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->whereNotNull('device_id')
->distinct('device_id')
->count('device_id');
$accountsReached = $reachedUsers + $reachedGuests;
return response()->json([
'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers,
'skip_rate' => $skipRate,
'skipped_views' => $skippedViews,
'skip_threshold' => $skipThreshold,
'save_count' => $saveCount,
'save_rate' => $saveRate,
'profile_visits' => $profileVisits,
'new_subscribers' => $newSubscribers,
'comments_count' => $commentsCount,
'accounts_reached' => $accountsReached,
'reached_users' => $reachedUsers,
'reached_guests' => $reachedGuests,
'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) : asset('images/default-avatar.svg'),
'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 ?: (\App\Data\Countries::name($country) ?? $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) : asset('images/default-avatar.svg'),
'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 ?: \App\Data\Countries::name($c->country), '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 ?: \App\Data\Countries::name($r->country),
'at' => $r->downloaded_at,
]);
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : asset('images/default-avatar.svg'),
],
'total' => $records->count(),
'records' => $records,
]);
}
// ── Backfill a device fingerprint after the JS computes it ────────────
// On the very first visit the cookie isn't there yet, so the view row is
// inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
// ms later with the freshly-computed hash so we can stamp it onto the row
// that was just inserted (and the future cookie does the rest).
public function identify(Request $request, Video $video)
{
$hash = (string) $request->input('hash', '');
if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
return response()->json(['ok' => false], 422);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
// Update the latest matching view row for this caller (user OR cookie OR IP)
// within a short window so we don't accidentally stamp someone else's row.
$q = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(10))
->whereNull('device_hash');
if (Auth::check()) {
$q->where('user_id', Auth::id());
} else {
$did = $request->cookie('_did');
$q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
if ($did) $w->where('device_id', $did);
$w->orWhere('ip_address', $ip);
});
}
$q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);
return response()->json(['ok' => true])
->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
}
// ── User-agent → coarse device / browser / OS labels ──────────────────
// Shared by every insights endpoint so labels stay consistent.
private function parseUserAgent(?string $ua): array
{
if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];
// Device family
$device = 'Desktop';
if (preg_match('/iPad/i', $ua)) $device = 'iPad';
elseif (preg_match('/Tablet/i', $ua)) $device = 'Tablet';
elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
elseif (preg_match('/Android/i', $ua)) $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
elseif (preg_match('/Mobile/i', $ua)) $device = 'Mobile';
// OS
$os = 'Unknown';
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows 10/11';
elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m)) $os = 'macOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m)) $os = 'Android ' . $m[1];
elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m)) $os = 'iOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m)) $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
// Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
$browser = 'Unknown';
if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m)) $browser = 'Edge ' . $m[1];
elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m)) $browser = 'Opera ' . $m[1];
elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m)) $browser = 'Firefox ' . $m[1];
elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m)) $browser = 'Chrome ' . $m[1];
elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m)) $browser = 'Safari ' . $m[1];
elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m)) $browser = 'Safari ' . $m[1];
return ['device' => $device, 'os' => $os, 'browser' => $browser];
}
// ── Drill-down: detail for one share link ─────────────────────────────
public function insightsShare(Video $video, string $token)
{
if (Auth::id() !== $video->user_id) abort(403);
$share = \DB::table('video_shares')
->where('video_id', $video->id)
->where('token', $token)
->first();
if (! $share) abort(404);
// Sharer profile
$sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
if ($share->user_id) {
$u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
if ($u) {
$sharer = [
'is_guest' => false,
'id' => $u->id,
'name' => $u->name,
'channel' => $u->username,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: asset('images/default-avatar.svg'),
];
}
}
$accesses = \DB::table('share_accesses')
->where('share_id', $share->id)
->orderByDesc('accessed_at')
->get();
// Country / device / browser / os aggregation
$countries = [];
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($accesses as $a) {
$code = $a->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
}
$countries[$code]['count']++;
$p = $this->parseUserAgent($a->user_agent ?? null);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
$recent = $accesses->take(50)->map(function ($a) {
$p = $this->parseUserAgent($a->user_agent ?? null);
return [
'at' => $a->accessed_at,
'country' => $a->country,
'device' => $p['device'],
'browser' => $p['browser'],
];
})->values();
return response()->json([
'token' => $share->token,
'created_at' => $share->created_at,
'sharer' => $sharer,
'reach' => $accesses->count(),
'countries' => array_values($countries),
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
// ── Drill-down: one viewer's full view history on this video ─────────
// $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
public function insightsViewer(Video $video, string $who)
{
if (Auth::id() !== $video->user_id) abort(403);
$who = substr($who, 0, 80);
$isUser = str_starts_with($who, 'u:');
$key = substr($who, 2);
$q = \DB::table('video_views')->where('video_id', $video->id);
if ($isUser) {
$q->where('user_id', (int) $key);
} else {
// Match across all three guest signals: fingerprint hash > device cookie > legacy IP
$q->whereNull('user_id')->where(function ($w) use ($key) {
$w->where('device_hash', $key)
->orWhere('device_id', $key)
->orWhere('ip_address', $key);
});
}
$rows = $q->orderByDesc('watched_at')
->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);
if ($rows->isEmpty()) abort(404);
// Build identity block
$identity = [
'is_guest' => ! $isUser,
'name' => 'Guest',
'avatar' => null,
'channel' => null,
];
if ($isUser) {
$user = \App\Models\User::find((int) $key);
if ($user) {
$identity = [
'is_guest' => false,
'id' => $user->id,
'name' => $user->name,
'channel' => $user->username,
'avatar' => $user->avatar
? route('media.avatar', $user->avatar)
: asset('images/default-avatar.svg'),
];
}
} else {
$identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = asset('images/default-avatar.svg');
}
// Country aggregation
$countries = [];
foreach ($rows as $r) {
$code = $r->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
}
$countries[$code]['count']++;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
// Device / browser / OS — parse on the fly from user_agent
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($rows as $r) {
$p = $this->parseUserAgent($r->user_agent);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
// Recent timestamps (cap 50 for the modal)
$recent = $rows->take(50)->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
]);
return response()->json([
'identity' => $identity,
'total' => $rows->count(),
'first_at' => $rows->last()->watched_at,
'last_at' => $rows->first()->watched_at,
'countries' => $countries,
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
/**
* Absolute path of the cache file for this match's OG PNG. The stamp
* embedded in the filename is sportsMatch.updated_at so a match edit
* automatically produces a fresh URL and stale variants get cleaned
* during the next render.
*/
public function cachedMatchOgPath(Video $video): string
{
$stamp = optional($video->sportsMatch->updated_at ?? $video->updated_at)->timestamp
?? $video->id;
// JPEG (not PNG) — WhatsApp silently rejects OG images above ~300 KB
// and the composed PNG lands at ~700 KB. A quality-85 JPEG of the
// same frame is 100-180 KB and looks identical for a share preview.
return storage_path("app/og-cache/match-{$video->id}-{$stamp}.jpg");
}
/**
* Render the OG PNG synchronously by spawning the artisan CLI and
* waiting for it to finish (~3–5s). Called from SportsMatchController
* on save so the file is guaranteed to exist before the user copies
* the share URL — critical because WhatsApp/Facebook crawl the URL
* once and cache whatever they get, so if the crawler beats the
* render we end up permanently stuck on the fallback thumbnail.
*
* Chrome won't launch under PHP-FPM's process context (missing HOME,
* stripped env). The artisan CLI subprocess is invoked with a full
* env explicitly restored, which sidesteps the FPM restriction.
*/
public function renderMatchOgSync(Video $video, int $timeoutSeconds = 45): bool
{
// Skip if the PNG for this exact stamp already exists.
$cacheFile = $this->cachedMatchOgPath($video);
if (is_file($cacheFile) && filesize($cacheFile) > 0) {
return true;
}
$php = '/usr/bin/php';
$art = base_path('artisan');
$cmd = sprintf(
'/usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d 2>&1',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id
);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$env = ['HOME' => '/tmp', 'TMPDIR' => '/tmp', 'PATH' => '/usr/bin:/bin'];
$proc = @proc_open($cmd, $descriptors, $pipes, '/tmp', $env);
if (! is_resource($proc)) {
\Log::warning('renderMatchOgSync: proc_open failed', ['video' => $video->id]);
return false;
}
stream_set_blocking($pipes[1], false);
$deadline = microtime(true) + $timeoutSeconds;
while (microtime(true) < $deadline) {
$status = proc_get_status($proc);
if (! $status['running']) break;
usleep(200000);
}
$out = @stream_get_contents($pipes[1]);
@fclose($pipes[1]);
@fclose($pipes[2]);
$exit = proc_close($proc);
if (! is_file($cacheFile)) {
\Log::warning('renderMatchOgSync: render finished without a PNG', [
'video' => $video->id,
'exit' => $exit,
'output' => $out,
]);
return false;
}
return true;
}
/**
* Fire the artisan renderer in a detached background process so this
* request can respond immediately with the fallback thumbnail. Used
* as a last-resort backfill when ogImage is hit and no PNG exists
* (e.g. matches that pre-date this feature).
*/
public function dispatchMatchOgRender(Video $video): void
{
// Skip if a render was fired for this stamp very recently — the
// marker file avoids stampeding multiple detached processes for
// repeated hits before the first one finishes.
$marker = $this->cachedMatchOgPath($video) . '.rendering';
if (is_file($marker) && (time() - filemtime($marker)) < 60) {
return;
}
@mkdir(dirname($marker), 0775, true);
@touch($marker);
// PHP_BINARY under FPM is /usr/sbin/php-fpm, which won't run
// artisan — must use the CLI binary explicitly.
$php = '/usr/bin/php';
$art = base_path('artisan');
$log = storage_path('logs/og-render.log');
// `setsid` detaches the child into a new session so it survives
// FPM's request teardown. `< /dev/null` closes stdin so bash
// treats it as fully backgrounded. `env HOME=/tmp` is mandatory:
// FPM clears the environment (clear_env = yes), the artisan
// command inherits that empty env, and Puppeteer/Chromium then
// fail with "Failed to launch the browser process: Code: null"
// because they can't resolve a user-data directory without HOME.
$cmd = sprintf(
'setsid /usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d >> %s 2>&1 < /dev/null &',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id,
escapeshellarg($log)
);
@exec($cmd);
}
/**
* Render (or return the cached) 1200×630 PNG of the sports-match VS
* composition. Cached under data/app/og-cache/match-{id}-{stamp}.png
* so a match edit (which bumps updated_at) automatically produces a
* fresh URL for social recrawlers.
*/
public function renderMatchOgImage(Video $video): ?string
{
$cacheFile = $this->cachedMatchOgPath($video);
$cacheDir = dirname($cacheFile);
if (is_file($cacheFile)) {
return @file_get_contents($cacheFile) ?: null;
}
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
// Purge older stamped variants for this match so the cache dir
// doesn't grow unbounded across edits. Includes the legacy .png
// extension in case any old files are still around.
foreach (glob($cacheDir . "/match-{$video->id}-*.{jpg,png}", GLOB_BRACE) ?: [] as $old) {
if ($old !== $cacheFile) @unlink($old);
}
@unlink($cacheFile . '.rendering');
try {
// Rebuild the URL as plain http on the app's own hostname so
// Chromium can reach it via loopback. --host-resolver-rules
// below points that hostname at 127.0.0.1:80 (nginx). This
// sidesteps Cloudflare (origin isn't reachable from itself
// over its public hostname) without needing a local TLS cert.
// Hit nginx over loopback. server_name is `_` (catch-all) so
// Host header doesn't matter. Chromium can't reach the origin
// over its public hostname (Cloudflare-fronted) and mapping
// the hostname via --host-resolver-rules trips ERR_BLOCKED_BY_CLIENT
// on public TLDs. vsPreview() forces http on generated URLs
// so all subresources (flag CSS, media thumbnails, headshots)
// also resolve to this loopback origin.
$url = 'http://127.0.0.1' . route('videos.vsOgFrame', $video, false);
$chrome = base_path('data/puppeteer-cache/chrome');
$binary = null;
if (is_dir($chrome)) {
$matches = glob($chrome . '/linux-*/chrome-linux64/chrome');
if ($matches) { $binary = $matches[0]; }
}
// PHP-FPM runs without a HOME env var; Chromium's user-data-dir
// resolution fails without one and the browser exits before
// Puppeteer can attach. /tmp is writable by www-data and gets
// cleaned periodically, which is fine for a screenshot job.
$shot = \Spatie\Browsershot\Browsershot::url($url)
->windowSize(1200, 675)
->deviceScaleFactor(1)
->waitUntilNetworkIdle()
// Output JPEG @ q85 (~150 KB) instead of PNG (~700 KB).
// WhatsApp silently drops OG images above roughly 300 KB.
->setScreenshotType('jpeg', 85)
->setNpmBinary('/usr/bin/npm')
// PHP-FPM starts with a stripped env (clear_env = yes) so
// Chromium sees no HOME/TMPDIR/PATH. Without HOME the
// user-data-dir can't be created; without TMPDIR crashpad's
// shmem sockets can't bind; without PATH Chromium can't
// fork its helper processes. Pass all three explicitly.
->setEnvironmentOptions([
'HOME' => '/tmp',
'TMPDIR' => '/tmp',
'PATH' => '/usr/bin:/bin',
])
// PHP-FPM defaults rlimit_files to 1024; Chromium opens
// way more than that on startup and dies silently
// ("Failed to launch the browser process: Code: null").
// The wrapper script re-execs node with a raised limit
// via prlimit, so we don't have to restart php-fpm to
// bump the pool-wide rlimit_files setting.
->setNodeBinary(base_path('bin/browsershot-node.sh'))
->noSandbox()
->addChromiumArguments([
'disable-gpu',
'hide-scrollbars',
'disable-dev-shm-usage',
// FPM's process context confuses Chromium's zygote —
// crashpad fails to fork with its args and chrome
// exits before puppeteer can attach. Isolating the
// user-data-dir per request + turning off crash
// reporting sidesteps the whole handler chain.
'user-data-dir=/tmp/chrome-og-' . getmypid() . '-' . mt_rand(),
'disable-crash-reporter',
'no-crash-upload',
]);
if ($binary) {
$shot->setChromePath($binary);
}
$shot->save($cacheFile);
} catch (\Throwable $e) {
\Log::warning('vs og render failed', [
'video' => $video->id,
'error' => $e->getMessage(),
]);
return null;
}
return is_file($cacheFile) ? (@file_get_contents($cacheFile) ?: null) : null;
}
}