- Add _ytpLoadSource(hlsUrl, mp4Url) to video-player component:
destroys old HLS instance, creates a new one with the new source,
then plays — browser retains autoplay permission since the <video>
element never leaves the page
- Add _ytpNavOverride hook: playlist overlay can replace navigateNext/
navigatePrev without modifying the component internals
- Add _plOnVideoEnd hook to 'ended' handler so the playlist overlay
can control autoplay/loop behavior independently
- Expose window._ytpHls for HLS instance lifecycle management
- Add hls_url + has_hls to /videos/{video}/player-data JSON endpoint
- Replace generic.blade.php playlist controls with full SPA system
identical in structure to music type: plTransitionTo, plSwapContent,
plAdj, plRender, plHighlight — no page refresh on track change
- Sidebar shows all playlist tracks; current track highlighted in red
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2210 lines
89 KiB
PHP
2210 lines
89 KiB
PHP
<?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,
|
||
]);
|
||
}
|
||
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
|
||
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();
|
||
} catch (\Throwable $e) {
|
||
\Log::error('uploadDirectToNas failed: ' . $e->getMessage());
|
||
}
|
||
|
||
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
|
||
if (! $isAudioUpload) {
|
||
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
||
->onQueue('video-processing')
|
||
->onConnection('database');
|
||
}
|
||
} else {
|
||
// ── Local storage: move into NAS-mirrored local directory schema ──
|
||
try {
|
||
$video->load('slides');
|
||
$nas->organizeLocalFiles($video);
|
||
$video->refresh();
|
||
} catch (\Throwable $e) {
|
||
\Log::error('organizeLocalFiles failed: ' . $e->getMessage());
|
||
}
|
||
|
||
// Compress + HLS pipeline for local storage
|
||
if (! $isAudioUpload) {
|
||
CompressVideoJob::dispatch($video)
|
||
->onQueue('video-processing')
|
||
->onConnection('database');
|
||
}
|
||
}
|
||
|
||
$video->load('user');
|
||
|
||
$userEmail = Auth::user()->email;
|
||
$userName = Auth::user()->name;
|
||
|
||
$uploader = Auth::user();
|
||
$subscribers = $uploader->subscribers()->get();
|
||
$subscriberEmails = $subscribers->pluck('email')->toArray();
|
||
|
||
// In-app DB notifications (fast — just inserts, no network)
|
||
if ($video->visibility === 'public') {
|
||
foreach ($subscribers as $subscriber) {
|
||
try {
|
||
$subscriber->notify(new NewVideoUploadedNotification($video, $uploader));
|
||
} catch (\Throwable $e) {
|
||
\Log::error('In-app notification error: '.$e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
app()->terminating(function () use ($video, $userEmail, $userName, $uploader, $subscriberEmails) {
|
||
// Confirm upload to the uploader
|
||
try {
|
||
Mail::to($userEmail)->send(new VideoUploaded($video, $userName));
|
||
} catch (\Throwable $e) {
|
||
\Log::error('Email error: '.$e->getMessage());
|
||
}
|
||
|
||
// Email subscribers (only for public videos)
|
||
if ($video->visibility === 'public' && count($subscriberEmails) > 0) {
|
||
foreach ($subscriberEmails as $email) {
|
||
try {
|
||
Mail::to($email)->send(new NewVideoNotification($video, $uploader));
|
||
} catch (\Throwable $e) {
|
||
\Log::error('Subscriber notification error: '.$e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
AuditLog::record('video.uploaded', [
|
||
'subject_type' => 'Video',
|
||
'subject_id' => (string) $video->id,
|
||
'subject_label' => $video->title,
|
||
'details' => ['type' => $video->type, 'visibility' => $video->visibility],
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'redirect' => route('videos.show', $video),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
\Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => $e->getMessage(),
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function showByToken(Request $request, string $token)
|
||
{
|
||
$video = Video::where('share_token', $token)->firstOrFail();
|
||
if (! $video->canView(Auth::user())) {
|
||
abort(404);
|
||
}
|
||
return $this->show($request, $video);
|
||
}
|
||
|
||
public function show(Request $request, Video $video)
|
||
{
|
||
if (! $video->canView(Auth::user())) {
|
||
$message = $video->isPrivate()
|
||
? 'This video is private.'
|
||
: 'This video is no longer available.';
|
||
return redirect('/')->with('toast_error', $message);
|
||
}
|
||
|
||
$ip = $request->header('CF-Connecting-IP')
|
||
?? $request->header('X-Real-IP')
|
||
?? $request->ip();
|
||
$geo = GeoIpService::lookup($ip);
|
||
|
||
if (Auth::check()) {
|
||
$exists = \DB::table('video_views')
|
||
->where('user_id', Auth::id())
|
||
->where('video_id', $video->id)
|
||
->where('watched_at', '>', now()->subHour())
|
||
->exists();
|
||
|
||
if (! $exists) {
|
||
\DB::table('video_views')->insert([
|
||
'user_id' => Auth::id(),
|
||
'video_id' => $video->id,
|
||
'ip_address' => $ip,
|
||
'country' => $geo['country'],
|
||
'country_name' => $geo['country_name'],
|
||
'watched_at' => now(),
|
||
]);
|
||
}
|
||
} else {
|
||
// Guest: deduplicate by IP within the last hour
|
||
$exists = \DB::table('video_views')
|
||
->whereNull('user_id')
|
||
->where('video_id', $video->id)
|
||
->where('ip_address', $ip)
|
||
->where('watched_at', '>', now()->subHour())
|
||
->exists();
|
||
|
||
if (! $exists) {
|
||
\DB::table('video_views')->insert([
|
||
'user_id' => null,
|
||
'video_id' => $video->id,
|
||
'ip_address' => $ip,
|
||
'country' => $geo['country'],
|
||
'country_name' => $geo['country_name'],
|
||
'watched_at' => now(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides']);
|
||
|
||
$playlist = null;
|
||
$nextVideo = null;
|
||
$previousVideo = null;
|
||
$playlistVideos = null;
|
||
|
||
$playlistParam = $request->query('playlist');
|
||
if ($playlistParam) {
|
||
$playlist = Playlist::where('share_token', $playlistParam)->first();
|
||
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
|
||
$nextVideo = $playlist->getNextVideo($video);
|
||
$previousVideo = $playlist->getPreviousVideo($video);
|
||
$playlistVideos = $playlist->videos;
|
||
}
|
||
}
|
||
|
||
$recommendedVideos = Video::public()
|
||
->where('id', '!=', $video->id)
|
||
->latest()
|
||
->limit(20)
|
||
->get();
|
||
|
||
$view = match ($video->type) {
|
||
'match' => 'videos.types.match',
|
||
'music' => 'videos.types.music',
|
||
default => 'videos.types.generic',
|
||
};
|
||
|
||
// Set persistent device-ID cookie used for share-link dedup
|
||
$did = $request->cookie('_did') ?: (string) Str::uuid();
|
||
|
||
return response()
|
||
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos'))
|
||
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||
}
|
||
|
||
public function playerData(Video $video, Request $request)
|
||
{
|
||
if (! $video->canView(Auth::user())) {
|
||
abort(403);
|
||
}
|
||
|
||
$coverUrl = $video->thumbnail
|
||
? route('media.thumbnail', $video->thumbnail)
|
||
: asset('storage/images/logo.png');
|
||
|
||
$slides = $video->slides->count() > 1
|
||
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||
: [];
|
||
|
||
return response()->json([
|
||
'id' => $video->id,
|
||
'key' => $video->getRouteKey(),
|
||
'type' => $video->type,
|
||
'has_hls' => (bool) $video->has_hls,
|
||
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
|
||
'stream_url' => route('videos.stream', $video),
|
||
'cover_url' => $coverUrl,
|
||
'slides' => $slides,
|
||
'title' => $video->title,
|
||
'author' => $video->user->name ?? '',
|
||
'duration' => $video->duration,
|
||
]);
|
||
}
|
||
|
||
public function matchData(Video $video)
|
||
{
|
||
if (! $video->canView(Auth::user())) {
|
||
abort(403);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
|
||
'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
|
||
]);
|
||
}
|
||
|
||
public function edit(Video $video, Request $request)
|
||
{
|
||
if (Auth::id() !== $video->user_id) {
|
||
abort(403);
|
||
}
|
||
|
||
if (! $request->expectsJson() && ! $request->ajax()) {
|
||
return view('videos.edit', compact('video'));
|
||
}
|
||
|
||
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
||
'id' => $s->id,
|
||
'url' => $s->url,
|
||
])->values();
|
||
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
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',
|
||
]);
|
||
|
||
$oldTitle = $video->title;
|
||
|
||
$data = $request->only(['title', 'description', 'visibility', 'type']);
|
||
$data['download_access'] = $request->input('download_access', 'disabled');
|
||
|
||
if ($request->hasFile('thumbnail')) {
|
||
if ($video->thumbnail) {
|
||
Storage::delete($video->thumbnailStorageKey());
|
||
}
|
||
if (str_starts_with($video->path, 'users/')) {
|
||
// New-format video: store thumbnail in the video's local dir
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
$localDir = $nas->localVideoDir($video);
|
||
@mkdir($localDir, 0755, true);
|
||
$ext = $request->file('thumbnail')->getClientOriginalExtension();
|
||
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
|
||
$userSlug = $nas->userSlug($video->user);
|
||
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
||
} else {
|
||
// Legacy video: push thumbnail directly to NAS
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||
$tempAbs = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||
$nasDir = $nas->resolveVideoDir($video);
|
||
$ext = pathinfo($thumbFilename, PATHINFO_EXTENSION);
|
||
$nas->mkdirp($nasDir);
|
||
$nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}");
|
||
@unlink($tempAbs);
|
||
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
||
}
|
||
}
|
||
|
||
if (! isset($data['visibility'])) {
|
||
unset($data['visibility']);
|
||
}
|
||
|
||
// Handle slide reorder / removal / additions for audio tracks
|
||
$slidesChanged = false;
|
||
if ($this->isAudioOnlyFile($video)) {
|
||
// slides_order is a JSON array of kept slide IDs in their new order
|
||
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
||
|
||
// Delete slides not in the kept list
|
||
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
|
||
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);
|
||
$isNewFormat = str_starts_with($video->path, 'users/');
|
||
$nasForSlides = app(\App\Services\NasSyncService::class);
|
||
if ($isNewFormat) {
|
||
$localDir = $nasForSlides->localVideoDir($video);
|
||
@mkdir("{$localDir}/slides", 0755, true);
|
||
$userSlug = $nasForSlides->userSlug($video->user);
|
||
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
||
}
|
||
foreach ($request->file('slides_add') as $file) {
|
||
if ($isNewFormat) {
|
||
// Create placeholder record to get the auto-increment ID
|
||
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||
$ext = $file->getClientOriginalExtension();
|
||
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
||
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
||
} else {
|
||
// Legacy video: push slide directly to NAS
|
||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||
$file->storeAs('public/thumbnails', $fname);
|
||
$tempAbs = storage_path('app/public/thumbnails/' . $fname);
|
||
$nasDir = $nasForSlides->resolveVideoDir($video);
|
||
$nasForSlides->mkdirp("{$nasDir}/slides");
|
||
$ext = pathinfo($fname, PATHINFO_EXTENSION);
|
||
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||
$nasSlide = "{$nasDir}/slides/{$slide->id}.{$ext}";
|
||
$nasForSlides->putFile($tempAbs, $nasSlide);
|
||
@unlink($tempAbs);
|
||
$slide->update(['filename' => $nasSlide]);
|
||
}
|
||
$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);
|
||
|
||
// If the title changed, rename the NAS/local folder before syncing so
|
||
// the sync job writes to the correctly-named directory.
|
||
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
|
||
try {
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
if ($nas->isEnabled()) {
|
||
$nas->renameVideoDir($video->fresh());
|
||
}
|
||
} catch (\Throwable $e) {
|
||
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
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($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',
|
||
'max:512000',
|
||
],
|
||
]);
|
||
|
||
$newFile = $request->file('replacement_file');
|
||
$mimeType = $newFile->getMimeType();
|
||
$isAudio = str_starts_with($mimeType, 'audio/');
|
||
$newSize = $newFile->getSize();
|
||
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
|
||
// ── 1. Clear old HLS ─────────────────────────────────────────────────
|
||
if ($video->has_hls && $video->hls_path) {
|
||
\Storage::deleteDirectory($video->hls_path);
|
||
}
|
||
|
||
// ── 2. Delete old media file ─────────────────────────────────────────
|
||
// NAS: only delete the video file; thumbnail/slides/meta.json stay
|
||
// Local: unlink the local copy
|
||
if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
|
||
try { $nas->deleteFile($video->path); } catch (\Throwable) {}
|
||
} else {
|
||
$oldLocal = storage_path('app/' . $video->path);
|
||
if (file_exists($oldLocal)) @unlink($oldLocal);
|
||
}
|
||
|
||
// ── 3. Store new file to a temporary local path ──────────────────────
|
||
$tempFilename = \Str::uuid() . '.' . $newExt;
|
||
$tempRelPath = 'public/videos/' . $tempFilename;
|
||
$newFile->storeAs('public/videos', $tempFilename);
|
||
$tempAbsPath = storage_path('app/' . $tempRelPath);
|
||
|
||
if (! file_exists($tempAbsPath)) {
|
||
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
|
||
}
|
||
|
||
// ── 4. Extract metadata via FFprobe ──────────────────────────────────
|
||
$width = $height = null;
|
||
$orientation = 'landscape';
|
||
$duration = 0;
|
||
|
||
try {
|
||
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||
$out = [];
|
||
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
|
||
$duration = (int) round((float) ($out[0] ?? 0));
|
||
|
||
if (! $isAudio) {
|
||
$ffprobe = \FFMpeg\FFProbe::create();
|
||
$stream = $ffprobe->streams($tempAbsPath)->videos()->first();
|
||
if ($stream) {
|
||
$width = $stream->get('width');
|
||
$height = $stream->get('height');
|
||
if ($width && $height) {
|
||
if ($height > $width) $orientation = 'portrait';
|
||
elseif ($width > $height) $orientation = 'landscape';
|
||
else $orientation = 'square';
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
\Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
|
||
}
|
||
|
||
// ── 5. Persist the file to its final location ─────────────────────────
|
||
//
|
||
// NAS path: push directly to NAS using uploadDirectToNas()
|
||
// This handles legacy paths correctly by computing the
|
||
// proper users/.../videos/... directory.
|
||
// uploadDirectToNas() updates path/filename in DB.
|
||
//
|
||
// Local path: update path/filename to temp location, then call
|
||
// organizeLocalFiles() which moves it to users/... layout.
|
||
// CompressVideoJob() sets status=ready and chains HLS.
|
||
|
||
if ($nas->isEnabled()) {
|
||
|
||
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
||
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
|
||
|
||
try {
|
||
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
||
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
||
$video->refresh();
|
||
} catch (\Throwable $e) {
|
||
\Log::error('replaceFile: NAS upload failed: ' . $e->getMessage());
|
||
@unlink($tempAbsPath);
|
||
return response()->json(['success' => false, 'message' => 'NAS upload failed. Please try again.'], 500);
|
||
}
|
||
|
||
$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 {
|
||
|
||
// Point the record at the temp file so organizeLocalFiles can move it
|
||
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
|
||
|
||
try {
|
||
$nas->organizeLocalFiles($video);
|
||
$video->refresh();
|
||
} catch (\Throwable $e) {
|
||
\Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
|
||
}
|
||
|
||
$metaUpdates = [
|
||
'size' => $newSize,
|
||
'mime_type'=> $mimeType,
|
||
'has_hls' => false,
|
||
'hls_path' => null,
|
||
'status' => $isAudio ? 'ready' : 'processing',
|
||
];
|
||
if (! $isAudio) {
|
||
$metaUpdates['duration'] = $duration ?: $video->duration;
|
||
$metaUpdates['width'] = $width ?: $video->width;
|
||
$metaUpdates['height'] = $height ?: $video->height;
|
||
$metaUpdates['orientation'] = $orientation;
|
||
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
||
}
|
||
$video->update($metaUpdates);
|
||
|
||
if (! $isAudio) {
|
||
\App\Jobs\CompressVideoJob::dispatch($video->fresh())
|
||
->onQueue('video-processing')
|
||
->onConnection('database');
|
||
}
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => $isAudio
|
||
? 'Audio file replaced successfully.'
|
||
: 'File replaced — re-encoding has started. The video will be ready shortly.',
|
||
'status' => $video->fresh()->status,
|
||
'is_audio' => $isAudio,
|
||
]);
|
||
}
|
||
|
||
public function trending(Request $request)
|
||
{
|
||
$hours = $request->get('hours', 48);
|
||
$limit = $request->get('limit', 50);
|
||
|
||
$hours = min(max($hours, 24), 168);
|
||
$limit = min(max($limit, 10), 100);
|
||
|
||
$videos = Video::public()
|
||
->where('status', 'ready')
|
||
->where('created_at', '>=', now()->subDays(10))
|
||
->with('user')
|
||
->get();
|
||
|
||
$videos = $videos->map(function ($video) use ($hours) {
|
||
$recentViews = \DB::table('video_views')
|
||
->where('video_id', $video->id)
|
||
->where('watched_at', '>=', now()->subHours($hours))
|
||
->count();
|
||
|
||
$likeCount = \DB::table('video_likes')
|
||
->where('video_id', $video->id)
|
||
->count();
|
||
|
||
$ageHours = $video->created_at->diffInHours(now());
|
||
$velocity = $recentViews / $hours;
|
||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||
|
||
$score = ($recentViews * 0.70) +
|
||
($velocity * 100 * 0.15) +
|
||
($recencyBonus * 50 * 0.10) +
|
||
($likeCount * 0.1 * 0.05);
|
||
|
||
$video->trending_score = round($score, 2);
|
||
$video->view_count = $recentViews;
|
||
$video->like_count = $likeCount;
|
||
|
||
return $video;
|
||
});
|
||
|
||
$trendingVideos = $videos
|
||
->filter(fn ($v) => $v->trending_score > 0)
|
||
->sortByDesc('trending_score')
|
||
->take($limit)
|
||
->values();
|
||
|
||
return view('videos.trending', [
|
||
'videos' => $trendingVideos,
|
||
'hours' => $hours,
|
||
'limit' => $limit,
|
||
]);
|
||
}
|
||
|
||
public function shorts(Request $request)
|
||
{
|
||
$videos = Video::public()
|
||
->where('is_shorts', true)
|
||
->where('status', 'ready')
|
||
->with('user')
|
||
->latest()
|
||
->get();
|
||
|
||
return view('videos.shorts', compact('videos'));
|
||
}
|
||
|
||
public function stream(Video $video)
|
||
{
|
||
if (! $video->canView(Auth::user())) {
|
||
abort(404, 'Video not found');
|
||
}
|
||
|
||
$path = $video->localVideoPath();
|
||
|
||
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
|
||
if (! file_exists($path)) {
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
$path = $nas->ensureLocalCopy($video);
|
||
if (! $path) {
|
||
abort(404, 'Video file not found');
|
||
}
|
||
}
|
||
|
||
$fileSize = filesize($path);
|
||
$mimeType = $video->mime_type ?: 'video/mp4';
|
||
|
||
$handle = fopen($path, 'rb');
|
||
if (! $handle) {
|
||
abort(500, 'Cannot open video file');
|
||
}
|
||
|
||
$range = request()->header('Range');
|
||
|
||
if ($range) {
|
||
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
||
$start = intval($matches[1] ?? 0);
|
||
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
||
|
||
$length = $end - $start + 1;
|
||
|
||
header('HTTP/1.1 206 Partial Content');
|
||
header('Content-Type: '.$mimeType);
|
||
header('Content-Length: '.$length);
|
||
header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
|
||
header('Accept-Ranges: bytes');
|
||
header('Cache-Control: 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 = $video->localVideoPath();
|
||
|
||
// If not on local disk, try to pull from NAS
|
||
if (! file_exists($path)) {
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
$path = $nas->ensureLocalCopy($video);
|
||
if (! $path) {
|
||
abort(404, 'Video file not found.');
|
||
}
|
||
}
|
||
|
||
// Already a video — serve directly, no conversion needed
|
||
if (! $this->isAudioOnlyFile($video)) {
|
||
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
||
return response()->download($path, $this->safeFilename($video->title, 'video') . '.' . $ext, [
|
||
'Content-Type' => $video->mime_type ?: 'video/mp4',
|
||
'Content-Disposition' => $this->contentDisposition($video->title, $ext),
|
||
]);
|
||
}
|
||
|
||
// Audio-only file → generate video
|
||
|
||
// Serve pre-generated slideshow — DB column must confirm it's the current version
|
||
if ($video->slideshow_video_path) {
|
||
$slideshowCache = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4');
|
||
if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
|
||
return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
|
||
'Content-Type' => 'video/mp4',
|
||
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
|
||
]);
|
||
}
|
||
// DB says cached but file is gone — clear the stale column
|
||
$video->update(['slideshow_video_path' => null]);
|
||
}
|
||
|
||
// Not cached — redirect to the video page which will auto-trigger the
|
||
// background generation progress bar (startSlideshowDownload). This avoids
|
||
// blocking the HTTP worker with a synchronous FFmpeg encode.
|
||
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
|
||
}
|
||
|
||
// ── Background slideshow generation + progress polling ────────
|
||
|
||
public function slideshowGenerate(Video $video)
|
||
{
|
||
$this->checkDownloadAccess($video);
|
||
|
||
if (! $this->isAudioOnlyFile($video)) {
|
||
return response()->json(['error' => 'Not an audio file'], 422);
|
||
}
|
||
|
||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||
$audioPath = $video->localVideoPath();
|
||
|
||
if (! file_exists($audioPath)) {
|
||
return response()->json(['error' => 'Audio file not found'], 404);
|
||
}
|
||
|
||
$cacheDir = storage_path('app/public/slideshow');
|
||
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||
|
||
$outPath = $cacheDir . '/' . $video->id . '_slideshow.mp4';
|
||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
|
||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
|
||
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . '.txt';
|
||
|
||
// Already cached — only trust it when the DB column confirms it's the right version
|
||
if ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0) {
|
||
return response()->json(['status' => 'ready']);
|
||
}
|
||
|
||
// File on disk but DB says it's stale (e.g. slides were edited) — delete it
|
||
if (file_exists($outPath)) {
|
||
@unlink($outPath);
|
||
}
|
||
|
||
// Already running — return so the frontend keeps polling
|
||
if (file_exists($pidFile)) {
|
||
$pid = (int) trim(file_get_contents($pidFile));
|
||
if ($pid > 0 && file_exists("/proc/{$pid}")) {
|
||
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
|
||
. escapeshellarg($audioPath) . ' 2>/dev/null', $dO);
|
||
return response()->json(['status' => 'running', 'duration' => (float) trim($dO[0] ?? '0')]);
|
||
}
|
||
}
|
||
|
||
// Probe duration
|
||
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
|
||
. escapeshellarg($audioPath) . ' 2>/dev/null', $durOut);
|
||
$dur = isset($durOut[0]) ? (float) trim($durOut[0]) : null;
|
||
if (! $dur || $dur < 1) {
|
||
return response()->json(['error' => 'Cannot probe audio duration'], 500);
|
||
}
|
||
|
||
$slides = $video->slides()->orderBy('position')->get();
|
||
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
|
||
|
||
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
|
||
|
||
@unlink($progressFile);
|
||
@unlink($pidFile);
|
||
|
||
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails
|
||
$isStillImage = ($validSlides->count() === 1);
|
||
$vFlags = $this->ffmpegVideoFlags($isStillImage);
|
||
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
|
||
|
||
if ($validSlides->count() >= 2) {
|
||
$n = $validSlides->count();
|
||
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
|
||
$T = ($dur + ($n - 1) * $fade) / $n;
|
||
|
||
$inputs = '';
|
||
$scaleFc = [];
|
||
foreach ($validSlides as $i => $slide) {
|
||
$imgPath = $slide->localPath();
|
||
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
|
||
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
|
||
}
|
||
$inputs .= ' -i ' . escapeshellarg($audioPath);
|
||
|
||
$xfadeFc = [];
|
||
$prev = '[s0]';
|
||
for ($i = 1; $i < $n; $i++) {
|
||
$offset = number_format($i * ($T - $fade), 3);
|
||
$outLabel = $i === $n - 1 ? '[vout]' : "[v{$i}]";
|
||
$xfadeFc[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
|
||
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
|
||
$prev = $outLabel;
|
||
}
|
||
if ($n === 1) $xfadeFc[] = '[s0]copy[vout]';
|
||
|
||
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
|
||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||
. ' -filter_complex ' . escapeshellarg($fc)
|
||
. ' -map [vout] -map ' . $n . ':a'
|
||
. ' ' . $vFlags
|
||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||
|
||
} elseif ($validSlides->count() === 1) {
|
||
$imgPath = $validSlides->first()->localPath();
|
||
$cmd = "{$ffmpeg} -y"
|
||
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
||
. ' -i ' . escapeshellarg($audioPath)
|
||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||
. ' -vf ' . escapeshellarg($scale)
|
||
. ' ' . $vFlags
|
||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||
} else {
|
||
$cmd = "{$ffmpeg} -y"
|
||
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
|
||
. ' -i ' . escapeshellarg($audioPath)
|
||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||
. ' ' . $vFlags
|
||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||
}
|
||
|
||
$cmd .= ' -progress ' . escapeshellarg($progressFile)
|
||
. ' ' . escapeshellarg($outPath);
|
||
|
||
// When GPU is active, wrap in a bash fallback: if GPU command fails, clear the
|
||
// progress file and retry immediately with CPU (libx264) so the download still works.
|
||
if (Setting::gpuEnabled() && $vFlags !== $cpuFlags) {
|
||
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
|
||
$inner = $cmd
|
||
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
|
||
$bgCmd = 'nohup bash -c ' . escapeshellarg($inner)
|
||
. ' 2>' . escapeshellarg($logFile)
|
||
. ' & echo $! > ' . escapeshellarg($pidFile);
|
||
} else {
|
||
$bgCmd = 'nohup ' . $cmd
|
||
. ' 2>' . escapeshellarg($logFile)
|
||
. ' & echo $! > ' . escapeshellarg($pidFile);
|
||
}
|
||
|
||
exec($bgCmd);
|
||
|
||
return response()->json(['status' => 'started', 'duration' => $dur]);
|
||
}
|
||
|
||
public function slideshowProgress(Video $video)
|
||
{
|
||
$outPath = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4');
|
||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
|
||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
|
||
|
||
// Read progress content once
|
||
$content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
|
||
$isDone = strpos($content, 'progress=end') !== false;
|
||
|
||
// If progress says done OR the output file exists and process is gone
|
||
if (! $isDone && file_exists($outPath) && filesize($outPath) > 0) {
|
||
$pid = file_exists($pidFile) ? (int) trim(file_get_contents($pidFile)) : 0;
|
||
if (! ($pid > 0 && file_exists("/proc/{$pid}"))) {
|
||
$isDone = true;
|
||
}
|
||
}
|
||
|
||
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
|
||
if (! $video->slideshow_video_path) {
|
||
$video->update(['slideshow_video_path' => 'public/slideshow/' . $video->id . '_slideshow.mp4']);
|
||
}
|
||
return response()->json(['percent' => 100, 'status' => 'ready']);
|
||
}
|
||
|
||
if (! $content) {
|
||
// If a PID was recorded but the process is already gone, generation failed
|
||
if (file_exists($pidFile)) {
|
||
$pid = (int) trim(file_get_contents($pidFile));
|
||
if ($pid > 0 && ! file_exists("/proc/{$pid}")) {
|
||
return response()->json(['percent' => 0, 'status' => 'error']);
|
||
}
|
||
}
|
||
return response()->json(['percent' => 0, 'status' => 'waiting']);
|
||
}
|
||
|
||
// Parse the most-recent out_time_ms value from the progress file
|
||
preg_match_all('/out_time_ms=(\d+)/', $content, $m);
|
||
$outTimeMs = ! empty($m[1]) ? (int) end($m[1]) : 0;
|
||
|
||
// FFmpeg's out_time_ms field is actually in microseconds despite its name
|
||
$totalUs = (float) request()->query('duration', 0) * 1_000_000;
|
||
$percent = $totalUs > 0 ? min(99, (int) round(($outTimeMs / $totalUs) * 100)) : 2;
|
||
|
||
return response()->json(['percent' => $percent, 'status' => 'processing']);
|
||
}
|
||
|
||
public function downloadMp3(Video $video)
|
||
{
|
||
$this->checkDownloadAccess($video);
|
||
|
||
$path = $video->localVideoPath();
|
||
|
||
if (! file_exists($path)) {
|
||
$nas = app(\App\Services\NasSyncService::class);
|
||
$path = $nas->ensureLocalCopy($video);
|
||
if (! $path) {
|
||
abort(404, 'Video file not found.');
|
||
}
|
||
}
|
||
|
||
$slug = $this->safeFilename($video->title, 'audio');
|
||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||
|
||
// Already MP3 — serve directly
|
||
if (strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)) === 'mp3') {
|
||
$this->recordDownload($video, 'mp3');
|
||
return response()->download($path, $this->safeFilename($video->title, 'audio') . '.mp3', [
|
||
'Content-Type' => 'audio/mpeg',
|
||
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
|
||
]);
|
||
}
|
||
|
||
$isAudio = $this->isAudioOnlyFile($video);
|
||
$hwaccel = $this->ffmpegHwaccelFlags(! $isAudio);
|
||
$escaped = escapeshellarg($path);
|
||
$tmp = sys_get_temp_dir() . '/' . \Str::uuid() . '.mp3';
|
||
|
||
$cmd = "{$ffmpeg} -y {$hwaccel}-i {$escaped}"
|
||
. " -vn -acodec libmp3lame -q:a 2"
|
||
. ' ' . escapeshellarg($tmp) . ' 2>/dev/null';
|
||
|
||
set_time_limit(0);
|
||
exec($cmd, $out, $exitCode);
|
||
|
||
if ($exitCode !== 0 || ! file_exists($tmp) || filesize($tmp) === 0) {
|
||
@unlink($tmp);
|
||
abort(500, 'Failed to generate MP3 file.');
|
||
}
|
||
|
||
$this->recordDownload($video, 'mp3');
|
||
|
||
return response()->download($tmp, $this->safeFilename($video->title, 'audio') . '.mp3', [
|
||
'Content-Type' => 'audio/mpeg',
|
||
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
|
||
])->deleteFileAfterSend(true);
|
||
}
|
||
|
||
private function safeFilename(string $title, string $fallback): string
|
||
{
|
||
// Strip characters illegal on Windows/Linux/macOS filesystems, then trim
|
||
$name = preg_replace('/[\\\\\/:\*\?"<>\|]/', '', $title);
|
||
$name = trim($name);
|
||
return $name !== '' ? $name : $fallback;
|
||
}
|
||
|
||
private function contentDisposition(string $title, string $ext): string
|
||
{
|
||
$ascii = \Str::slug($title) ?: 'download';
|
||
$safe = $this->safeFilename($title, $ascii);
|
||
$utf8 = rawurlencode($safe . '.' . $ext);
|
||
return 'attachment; filename="' . $ascii . '.' . $ext . '"; filename*=UTF-8\'\'' . $utf8;
|
||
}
|
||
|
||
private function downloadSlideshowVideo(Video $video, $slides, string $audioPath, string $ffmpeg)
|
||
{
|
||
if ($video->slideshow_video_path) {
|
||
$cached = storage_path('app/' . $video->slideshow_video_path);
|
||
if (file_exists($cached) && filesize($cached) > 0) {
|
||
return response()->download($cached, $this->safeFilename($video->title, 'video') . '.mp4', [
|
||
'Content-Type' => 'video/mp4',
|
||
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
|
||
]);
|
||
}
|
||
}
|
||
|
||
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
|
||
}
|
||
|
||
/** Full video codec flags for raw FFmpeg shell commands (GPU if enabled, else CPU). */
|
||
private function ffmpegVideoFlags(bool $stillImage = false): string
|
||
{
|
||
return Setting::ffmpegVideoFlags($stillImage);
|
||
}
|
||
|
||
/** CPU-only codec flags — used as fallback when GPU encoding fails. */
|
||
private function ffmpegVideoFlagsCpu(bool $stillImage = false): string
|
||
{
|
||
return Setting::ffmpegVideoFlagsCpu($stillImage);
|
||
}
|
||
|
||
/** hwaccel decode flags — only used when input is a real video file and GPU is on. */
|
||
private function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
||
{
|
||
return Setting::ffmpegHwaccelFlags($inputIsVideo);
|
||
}
|
||
|
||
private function isAudioOnlyFile(Video $video): bool
|
||
{
|
||
if ($video->mime_type) {
|
||
return str_starts_with($video->mime_type, 'audio/');
|
||
}
|
||
$audioExts = ['mp3', 'aac', 'm4a', 'ogg', 'wav', 'flac', 'opus', 'wma'];
|
||
return in_array(strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)), $audioExts);
|
||
}
|
||
|
||
private function recordDownload(Video $video, string $type): void
|
||
{
|
||
$request = request();
|
||
$ip = $request->header('CF-Connecting-IP')
|
||
?? $request->header('X-Real-IP')
|
||
?? $request->ip();
|
||
$userId = Auth::id();
|
||
|
||
// Deduplicate: browsers/download managers sometimes retry the connection.
|
||
// Skip recording if the same user (or guest IP) already downloaded this
|
||
// video+type within the last 10 minutes.
|
||
$alreadyRecorded = \DB::table('video_downloads')
|
||
->where('video_id', $video->id)
|
||
->where('type', $type)
|
||
->where('downloaded_at', '>=', now()->subMinutes(10))
|
||
->when(
|
||
$userId !== null,
|
||
fn ($q) => $q->where('user_id', $userId),
|
||
fn ($q) => $q->whereNull('user_id')->where('ip_address', $ip)
|
||
)
|
||
->exists();
|
||
|
||
if ($alreadyRecorded) {
|
||
return;
|
||
}
|
||
|
||
$geo = GeoIpService::lookup($ip);
|
||
|
||
\DB::table('video_downloads')->insert([
|
||
'video_id' => $video->id,
|
||
'user_id' => $userId,
|
||
'ip_address' => $ip,
|
||
'country' => $geo['country'],
|
||
'country_name' => $geo['country_name'],
|
||
'type' => $type,
|
||
'downloaded_at' => now(),
|
||
]);
|
||
|
||
\DB::table('videos')->where('id', $video->id)->increment('download_count');
|
||
}
|
||
|
||
public function recordShare(Video $video)
|
||
{
|
||
$userId = Auth::id();
|
||
|
||
// Authenticated users reuse their existing share token for this video
|
||
if ($userId) {
|
||
$existing = \DB::table('video_shares')
|
||
->where('video_id', $video->id)
|
||
->where('user_id', $userId)
|
||
->first();
|
||
if ($existing) {
|
||
return response()->json(['url' => route('share.access', $existing->token)]);
|
||
}
|
||
}
|
||
|
||
// Generate a unique 10-char token
|
||
do {
|
||
$token = Str::random(10);
|
||
} while (\DB::table('video_shares')->where('token', $token)->exists());
|
||
|
||
\DB::table('video_shares')->insert([
|
||
'video_id' => $video->id,
|
||
'user_id' => $userId,
|
||
'token' => $token,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
return response()->json(['url' => route('share.access', $token)]);
|
||
}
|
||
|
||
public function accessShare(Request $request, string $token)
|
||
{
|
||
$share = \DB::table('video_shares')->where('token', $token)->first();
|
||
if (! $share) {
|
||
return redirect('/');
|
||
}
|
||
|
||
$video = Video::find($share->video_id);
|
||
if (! $video || ! $video->canView(Auth::user())) {
|
||
return redirect('/');
|
||
}
|
||
|
||
// Identify device via persistent cookie; generate one for first-time visitors
|
||
$did = $request->cookie('_did') ?: (string) Str::uuid();
|
||
|
||
// Record only the first access from this device for this share link
|
||
$seen = \DB::table('share_accesses')
|
||
->where('share_id', $share->id)
|
||
->where('device_id', $did)
|
||
->exists();
|
||
|
||
if (! $seen) {
|
||
$ip = $request->header('CF-Connecting-IP')
|
||
?? $request->header('X-Real-IP')
|
||
?? $request->ip();
|
||
$geo = GeoIpService::lookup($ip);
|
||
|
||
\DB::table('share_accesses')->insert([
|
||
'share_id' => $share->id,
|
||
'device_id' => $did,
|
||
'ip_address' => $ip,
|
||
'country' => $geo['country'] ?? null,
|
||
'country_name'=> $geo['country_name'] ?? null,
|
||
'accessed_at' => now(),
|
||
]);
|
||
}
|
||
|
||
return redirect(route('videos.showByToken', $video->share_token))
|
||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||
}
|
||
|
||
public function ogImage(Video $video)
|
||
{
|
||
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
|
||
if ($video->thumbnail) {
|
||
$path = $video->localThumbnailPath();
|
||
if ($path && file_exists($path)) {
|
||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||
// Load source image via GD
|
||
$src = match($ext) {
|
||
'png' => @imagecreatefrompng($path),
|
||
'webp' => @imagecreatefromwebp($path),
|
||
'gif' => @imagecreatefromgif($path),
|
||
default => @imagecreatefromjpeg($path),
|
||
};
|
||
if ($src) {
|
||
$ow = imagesx($src); $oh = imagesy($src);
|
||
// Fit inside 1200×630, preserving aspect ratio
|
||
$maxW = 1200; $maxH = 630;
|
||
$ratio = min($maxW / $ow, $maxH / $oh, 1.0);
|
||
$nw = (int)round($ow * $ratio); $nh = (int)round($oh * $ratio);
|
||
$dst = imagecreatetruecolor($nw, $nh);
|
||
// Preserve transparency before converting to JPEG (fill white)
|
||
$white = imagecolorallocate($dst, 255, 255, 255);
|
||
imagefill($dst, 0, 0, $white);
|
||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh);
|
||
imagedestroy($src);
|
||
ob_start();
|
||
imagejpeg($dst, null, 82);
|
||
imagedestroy($dst);
|
||
$jpeg = ob_get_clean();
|
||
return response($jpeg, 200, [
|
||
'Content-Type' => 'image/jpeg',
|
||
'Cache-Control' => 'public, max-age=86400',
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Generate a branded 1200×630 fallback card
|
||
$w = 1200; $h = 630;
|
||
$img = imagecreatetruecolor($w, $h);
|
||
|
||
$cBg = imagecolorallocate($img, 10, 10, 10);
|
||
$cRed = imagecolorallocate($img, 230, 30, 30);
|
||
$cWhite = imagecolorallocate($img, 240, 240, 240);
|
||
$cGray = imagecolorallocate($img, 120, 120, 120);
|
||
$cDark = imagecolorallocate($img, 28, 28, 28);
|
||
|
||
imagefill($img, 0, 0, $cBg);
|
||
|
||
// Subtle vignette border
|
||
imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, $cDark);
|
||
imagefilledrectangle($img, 4, 4, $w - 5, $h - 5, $cBg);
|
||
|
||
// Red accent top bar
|
||
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
|
||
|
||
// Red accent bottom bar
|
||
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
|
||
|
||
// Play button circle (center)
|
||
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30;
|
||
$r = 72;
|
||
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
|
||
// Play triangle
|
||
$tri = [
|
||
$cx - 22, $cy - 30,
|
||
$cx - 22, $cy + 30,
|
||
$cx + 34, $cy,
|
||
];
|
||
imagefilledpolygon($img, $tri, $cWhite);
|
||
|
||
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
|
||
|
||
// App name (top-left)
|
||
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
|
||
|
||
// Video title (wrap long titles)
|
||
$title = $video->title ?: 'Video';
|
||
$maxChars = 42;
|
||
if (mb_strlen($title) > $maxChars) {
|
||
$lines = [];
|
||
$words = explode(' ', $title);
|
||
$line = '';
|
||
foreach ($words as $word) {
|
||
if (mb_strlen($line . ' ' . $word) > $maxChars) {
|
||
$lines[] = trim($line);
|
||
$line = $word;
|
||
} else {
|
||
$line .= ($line ? ' ' : '') . $word;
|
||
}
|
||
}
|
||
if ($line) $lines[] = trim($line);
|
||
} else {
|
||
$lines = [$title];
|
||
}
|
||
$lines = array_slice($lines, 0, 2);
|
||
$titleY = $cy + $r + 60;
|
||
foreach ($lines as $i => $line) {
|
||
$bbox = imagettfbbox(28, 0, $fontBold, $line);
|
||
$tw = $bbox[2] - $bbox[0];
|
||
$tx = (int)(($w - $tw) / 2);
|
||
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
|
||
}
|
||
|
||
// Channel name (below title)
|
||
$channel = $video->user?->name ?? config('app.name');
|
||
$bbox = imagettfbbox(16, 0, $fontNormal, $channel);
|
||
$tw = $bbox[2] - $bbox[0];
|
||
$tx = (int)(($w - $tw) / 2);
|
||
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $channel);
|
||
|
||
// Domain (bottom-right)
|
||
$domain = parse_url(config('app.url'), PHP_URL_HOST) ?? config('app.url');
|
||
imagettftext($img, 14, 0, $w - 300, $h - 24, $cGray, $fontNormal, $domain);
|
||
|
||
ob_start();
|
||
imagejpeg($img, null, 85);
|
||
imagedestroy($img);
|
||
$jpeg = ob_get_clean();
|
||
|
||
return response($jpeg, 200, [
|
||
'Content-Type' => 'image/jpeg',
|
||
'Cache-Control' => 'public, max-age=86400',
|
||
]);
|
||
}
|
||
|
||
public function insights(Video $video)
|
||
{
|
||
if (Auth::id() !== $video->user_id) {
|
||
abort(403);
|
||
}
|
||
|
||
$id = $video->id;
|
||
$now = now();
|
||
|
||
$totalViews = \DB::table('video_views')->where('video_id', $id)->count();
|
||
|
||
$uniqueViewers = \DB::table('video_views')
|
||
->where('video_id', $id)
|
||
->whereNotNull('user_id')
|
||
->distinct('user_id')
|
||
->count('user_id');
|
||
|
||
$viewsToday = \DB::table('video_views')
|
||
->where('video_id', $id)
|
||
->where('watched_at', '>=', $now->copy()->startOfDay())
|
||
->count();
|
||
|
||
$viewsThisWeek = \DB::table('video_views')
|
||
->where('video_id', $id)
|
||
->where('watched_at', '>=', $now->copy()->subDays(7))
|
||
->count();
|
||
|
||
$viewsLastWeek = \DB::table('video_views')
|
||
->where('video_id', $id)
|
||
->whereBetween('watched_at', [
|
||
$now->copy()->subDays(14),
|
||
$now->copy()->subDays(7),
|
||
])
|
||
->count();
|
||
|
||
$weekChange = $viewsLastWeek > 0
|
||
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
|
||
: ($viewsThisWeek > 0 ? 100 : 0);
|
||
|
||
// 14-day daily breakdown
|
||
$rawDaily = \DB::table('video_views')
|
||
->selectRaw("date(watched_at) as day, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
|
||
->groupByRaw("date(watched_at)")
|
||
->orderBy('day')
|
||
->pluck('cnt', 'day');
|
||
|
||
$daily = [];
|
||
for ($i = 13; $i >= 0; $i--) {
|
||
$d = $now->copy()->subDays($i);
|
||
$daily[] = [
|
||
'date' => $d->format('Y-m-d'),
|
||
'label' => $d->format('M d'),
|
||
'short' => $d->format('D'),
|
||
'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0),
|
||
];
|
||
}
|
||
|
||
// Country breakdown (top 10)
|
||
$rawCountries = \DB::table('video_views')
|
||
->selectRaw("country, country_name, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->whereNotNull('country')
|
||
->groupBy('country', 'country_name')
|
||
->orderByDesc('cnt')
|
||
->limit(10)
|
||
->get();
|
||
|
||
$totalGeo = $rawCountries->sum('cnt');
|
||
$countries = $rawCountries->map(fn ($c) => [
|
||
'code' => $c->country,
|
||
'name' => $c->country_name,
|
||
'count' => (int) $c->cnt,
|
||
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
||
])->values();
|
||
|
||
// Peak hour (0-23) across all views
|
||
$driver = \DB::getDriverName();
|
||
$hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)';
|
||
$peakRow = \DB::table('video_views')
|
||
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->groupByRaw("{$hourExpr}")
|
||
->orderByDesc('cnt')
|
||
->first();
|
||
$peakHour = $peakRow ? (int) $peakRow->hr : null;
|
||
|
||
// Top registered viewers (by view count)
|
||
$topViewers = \DB::table('video_views')
|
||
->join('users', 'users.id', '=', 'video_views.user_id')
|
||
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
|
||
->where('video_views.video_id', $id)
|
||
->whereNotNull('video_views.user_id')
|
||
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
|
||
->orderByDesc('cnt')
|
||
->limit(20)
|
||
->get()
|
||
->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'channel' => $u->username,
|
||
'name' => $u->name,
|
||
'avatar' => $u->avatar
|
||
? route('media.avatar', $u->avatar)
|
||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||
'count' => (int) $u->cnt,
|
||
'last_at' => $u->last_at,
|
||
]);
|
||
|
||
$guestViews = \DB::table('video_views')
|
||
->where('video_id', $id)
|
||
->whereNull('user_id')
|
||
->count();
|
||
|
||
// 10 most recent views
|
||
$recentViewers = \DB::table('video_views')
|
||
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
|
||
->select(
|
||
'video_views.watched_at',
|
||
'video_views.country',
|
||
'users.id as user_id',
|
||
'users.name as user_name',
|
||
'users.avatar as user_avatar',
|
||
'users.username as user_channel'
|
||
)
|
||
->where('video_views.video_id', $id)
|
||
->orderByDesc('video_views.watched_at')
|
||
->limit(10)
|
||
->get()
|
||
->map(fn ($r) => [
|
||
'at' => $r->watched_at,
|
||
'country' => $r->country,
|
||
'user_id' => $r->user_id,
|
||
'user_channel' => $r->user_channel,
|
||
'user_name' => $r->user_name ?? 'Guest',
|
||
'user_avatar' => $r->user_id
|
||
? ($r->user_avatar
|
||
? route('media.avatar', $r->user_avatar)
|
||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||
: null,
|
||
]);
|
||
|
||
// Download details
|
||
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
|
||
|
||
// Per-type breakdown
|
||
$dlByType = \DB::table('video_downloads')
|
||
->selectRaw('type, count(*) as cnt')
|
||
->where('video_id', $id)
|
||
->groupBy('type')
|
||
->pluck('cnt', 'type');
|
||
|
||
// Per-user download counts (top 20 logged-in users)
|
||
$dlUsers = \DB::table('video_downloads')
|
||
->join('users', 'users.id', '=', 'video_downloads.user_id')
|
||
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
|
||
->where('video_downloads.video_id', $id)
|
||
->whereNotNull('video_downloads.user_id')
|
||
->groupBy('users.id', 'users.name', 'users.avatar')
|
||
->orderByDesc('cnt')
|
||
->limit(20)
|
||
->get()
|
||
->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'name' => $u->name,
|
||
'avatar' => $u->avatar
|
||
? route('media.avatar', $u->avatar)
|
||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||
'count' => (int) $u->cnt,
|
||
'last_at' => $u->last_at,
|
||
]);
|
||
|
||
$dlGuests = \DB::table('video_downloads')
|
||
->where('video_id', $id)
|
||
->whereNull('user_id')
|
||
->count();
|
||
|
||
// 10 most recent downloads
|
||
$dlRecent = \DB::table('video_downloads')
|
||
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
|
||
->select(
|
||
'video_downloads.id',
|
||
'video_downloads.type',
|
||
'video_downloads.downloaded_at',
|
||
'video_downloads.country',
|
||
'video_downloads.country_name',
|
||
'users.id as user_id',
|
||
'users.name as user_name',
|
||
'users.avatar as user_avatar'
|
||
)
|
||
->where('video_downloads.video_id', $id)
|
||
->orderByDesc('video_downloads.downloaded_at')
|
||
->limit(10)
|
||
->get()
|
||
->map(fn ($r) => [
|
||
'type' => $r->type,
|
||
'at' => $r->downloaded_at,
|
||
'country' => $r->country,
|
||
'country_name'=> $r->country_name,
|
||
'user_id' => $r->user_id,
|
||
'user_name' => $r->user_name ?? 'Guest',
|
||
'user_avatar'=> $r->user_id
|
||
? ($r->user_avatar
|
||
? route('media.avatar', $r->user_avatar)
|
||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||
: null,
|
||
]);
|
||
|
||
// Gender breakdown (authenticated viewers only)
|
||
$genderRows = \DB::table('video_views')
|
||
->join('users', 'users.id', '=', 'video_views.user_id')
|
||
->selectRaw('users.gender, count(*) as cnt')
|
||
->where('video_views.video_id', $id)
|
||
->whereNotNull('video_views.user_id')
|
||
->whereNotNull('users.gender')
|
||
->groupBy('users.gender')
|
||
->get();
|
||
|
||
$totalGender = $genderRows->sum('cnt');
|
||
$genders = $genderRows->map(fn ($g) => [
|
||
'gender' => $g->gender,
|
||
'count' => (int) $g->cnt,
|
||
'pct' => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0,
|
||
])->values();
|
||
|
||
// Age group breakdown (authenticated viewers with birthday set)
|
||
$ageExpr = $driver === 'sqlite'
|
||
? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)"
|
||
: "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)";
|
||
|
||
$rawAges = \DB::table('video_views')
|
||
->join('users', 'users.id', '=', 'video_views.user_id')
|
||
->selectRaw("{$ageExpr} as age")
|
||
->where('video_views.video_id', $id)
|
||
->whereNotNull('video_views.user_id')
|
||
->whereNotNull('users.birthday')
|
||
->get();
|
||
|
||
$ageBuckets = ['Under 13' => 0, '13–17' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55–64' => 0, '65+' => 0];
|
||
foreach ($rawAges as $row) {
|
||
$age = (int) $row->age;
|
||
$bucket = match (true) {
|
||
$age < 13 => 'Under 13',
|
||
$age <= 17 => '13–17',
|
||
$age <= 24 => '18–24',
|
||
$age <= 34 => '25–34',
|
||
$age <= 44 => '35–44',
|
||
$age <= 54 => '45–54',
|
||
$age <= 64 => '55–64',
|
||
default => '65+',
|
||
};
|
||
$ageBuckets[$bucket]++;
|
||
}
|
||
|
||
$totalAge = array_sum($ageBuckets);
|
||
$ageGroups = collect($ageBuckets)
|
||
->filter(fn ($cnt) => $cnt > 0)
|
||
->map(fn ($cnt, $label) => [
|
||
'label' => $label,
|
||
'count' => $cnt,
|
||
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
|
||
])->values();
|
||
|
||
// Who liked this video
|
||
$likers = \DB::table('video_likes')
|
||
->join('users', 'users.id', '=', 'video_likes.user_id')
|
||
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
|
||
->where('video_likes.video_id', $id)
|
||
->orderByDesc('video_likes.created_at')
|
||
->limit(50)
|
||
->get()
|
||
->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'channel' => $u->username,
|
||
'name' => $u->name,
|
||
'avatar' => $u->avatar
|
||
? route('media.avatar', $u->avatar)
|
||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||
'liked_at' => $u->liked_at,
|
||
]);
|
||
|
||
// ── Share analytics ────────────────────────────────────────
|
||
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
|
||
$shareIds = $shareLinks->pluck('id');
|
||
|
||
$shareReach = $shareIds->isEmpty() ? 0
|
||
: \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count();
|
||
|
||
$shareLinksCount = $shareLinks->count();
|
||
|
||
// Per-link breakdown for insights panel
|
||
$shareBreakdown = $shareLinks->map(function ($s) {
|
||
$accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();
|
||
$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,
|
||
'likers' => $likers,
|
||
'genders' => $genders,
|
||
'age_groups' => $ageGroups,
|
||
]);
|
||
}
|
||
|
||
// ── Drill-down: viewers from one country ──────────────────────────────
|
||
public function insightsCountry(Video $video, string $country)
|
||
{
|
||
if (Auth::id() !== $video->user_id) abort(403);
|
||
|
||
$id = $video->id;
|
||
$country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2));
|
||
|
||
$totalViews = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count();
|
||
$countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name');
|
||
$guestCount = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count();
|
||
|
||
$registeredUsers = \DB::table('video_views')
|
||
->join('users', 'users.id', '=', 'video_views.user_id')
|
||
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
|
||
->where('video_views.video_id', $id)
|
||
->where('video_views.country', $country)
|
||
->whereNotNull('video_views.user_id')
|
||
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
|
||
->orderByDesc('cnt')
|
||
->limit(20)
|
||
->get()
|
||
->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'channel' => $u->username,
|
||
'name' => $u->name,
|
||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||
'count' => (int) $u->cnt,
|
||
'last_at' => $u->last_at,
|
||
]);
|
||
|
||
// 14-day trend for this country
|
||
$now = now();
|
||
$rawDaily = \DB::table('video_views')
|
||
->selectRaw("date(watched_at) as day, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->where('country', $country)
|
||
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
|
||
->groupByRaw("date(watched_at)")
|
||
->orderBy('day')
|
||
->pluck('cnt', 'day');
|
||
|
||
$daily = [];
|
||
for ($i = 13; $i >= 0; $i--) {
|
||
$d = $now->copy()->subDays($i);
|
||
$daily[] = ['label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0)];
|
||
}
|
||
|
||
return response()->json([
|
||
'country' => $country,
|
||
'country_name' => $countryName ?? $country,
|
||
'total_views' => $totalViews,
|
||
'registered_users' => $registeredUsers,
|
||
'guest_count' => $guestCount,
|
||
'daily' => $daily,
|
||
]);
|
||
}
|
||
|
||
// ── Drill-down: viewers on a specific day ─────────────────────────────
|
||
public function insightsDay(Video $video, string $date)
|
||
{
|
||
if (Auth::id() !== $video->user_id) abort(403);
|
||
|
||
try {
|
||
$day = \Carbon\Carbon::createFromFormat('Y-m-d', $date, config('app.timezone'));
|
||
} catch (\Throwable) {
|
||
abort(400, 'Invalid date');
|
||
}
|
||
|
||
$id = $video->id;
|
||
$start = $day->copy()->startOfDay();
|
||
$end = $day->copy()->endOfDay();
|
||
|
||
$totalViews = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->count();
|
||
$guestCount = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->whereNull('user_id')->count();
|
||
|
||
$registeredUsers = \DB::table('video_views')
|
||
->join('users', 'users.id', '=', 'video_views.user_id')
|
||
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
|
||
->where('video_views.video_id', $id)
|
||
->whereBetween('video_views.watched_at', [$start, $end])
|
||
->whereNotNull('video_views.user_id')
|
||
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
|
||
->orderByDesc('cnt')
|
||
->limit(20)
|
||
->get()
|
||
->map(fn ($u) => [
|
||
'id' => $u->id,
|
||
'channel' => $u->username,
|
||
'name' => $u->name,
|
||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||
'count' => (int) $u->cnt,
|
||
'last_at' => $u->last_at,
|
||
]);
|
||
|
||
// Hourly breakdown (0-23)
|
||
$driver = \DB::getDriverName();
|
||
$hourExpr = $driver === 'sqlite' ? "CAST(strftime('%H', watched_at) AS INTEGER)" : 'HOUR(watched_at)';
|
||
$rawHourly = \DB::table('video_views')
|
||
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->whereBetween('watched_at', [$start, $end])
|
||
->groupByRaw($hourExpr)
|
||
->orderBy('hr')
|
||
->pluck('cnt', 'hr');
|
||
|
||
$hourly = [];
|
||
for ($h = 0; $h < 24; $h++) {
|
||
$hourly[] = [
|
||
'hour' => $h,
|
||
'label' => ($h === 0 ? '12am' : ($h < 12 ? $h . 'am' : ($h === 12 ? '12pm' : ($h - 12) . 'pm'))),
|
||
'count' => (int) ($rawHourly->get($h) ?? 0),
|
||
];
|
||
}
|
||
|
||
// Top countries that day
|
||
$countries = \DB::table('video_views')
|
||
->selectRaw("country, country_name, count(*) as cnt")
|
||
->where('video_id', $id)
|
||
->whereBetween('watched_at', [$start, $end])
|
||
->whereNotNull('country')
|
||
->groupBy('country', 'country_name')
|
||
->orderByDesc('cnt')
|
||
->limit(5)
|
||
->get()
|
||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
|
||
|
||
return response()->json([
|
||
'date' => $day->format('M d, Y'),
|
||
'day_of_week' => $day->format('l'),
|
||
'total_views' => $totalViews,
|
||
'registered_users' => $registeredUsers,
|
||
'guest_count' => $guestCount,
|
||
'hourly' => $hourly,
|
||
'countries' => $countries,
|
||
]);
|
||
}
|
||
|
||
// ── Drill-down: one user's full download history on this video ────────
|
||
public function insightsDownloaderHistory(Video $video, int $userId)
|
||
{
|
||
if (Auth::id() !== $video->user_id) abort(403);
|
||
|
||
$user = \App\Models\User::findOrFail($userId);
|
||
|
||
$records = \DB::table('video_downloads')
|
||
->where('video_id', $video->id)
|
||
->where('user_id', $userId)
|
||
->orderByDesc('downloaded_at')
|
||
->get(['id', 'type', 'country', 'country_name', 'downloaded_at'])
|
||
->map(fn ($r) => [
|
||
'type' => $r->type,
|
||
'country' => $r->country,
|
||
'country_name' => $r->country_name,
|
||
'at' => $r->downloaded_at,
|
||
]);
|
||
|
||
return response()->json([
|
||
'user' => [
|
||
'id' => $user->id,
|
||
'name' => $user->name,
|
||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||
],
|
||
'total' => $records->count(),
|
||
'records' => $records,
|
||
]);
|
||
}
|
||
}
|
||
|