ghassan 0b75acec89 Make NAS the primary storage when enabled (not a mirror)
When NAS sync is enabled:
- Audio uploads: pushed to NAS via NasSyncVideoJob, local file deleted immediately after
- Video uploads: processed locally (ffprobe, compress, HLS), then at the end of
  GenerateHlsJob the final compressed file is re-synced to NAS and the local copy removed
- stream() and download(): if local file is missing, pull from NAS into a local
  stream cache (storage/app/nas_cache/videos/) and serve from there with full
  byte-range support — so seeking still works over NAS-sourced files

When NAS is disabled:
- Upload, stream, and download all use local storage exclusively (no change)

HLS segments are intentionally kept local: they are small, generated on-demand,
and serving them via per-segment SMB round-trips would hurt playback performance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:56:55 +03:00

1884 lines
74 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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