ghassan 73527f3781 Add sports-match type, device tracking, profile visits, and share refactor
- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
2026-05-29 01:50:28 +03:00

3447 lines
149 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\VideoAudioTrack;
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', 'playerData', 'streamAudioTrack']);
}
public function index()
{
$filter = request('filter', 'all');
// ── Playlists-only browse ──────────────────────────────────
if ($filter === 'playlists') {
$playlists = Playlist::where('visibility', 'public')
->withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(60)
->get();
return view('videos.index', compact('playlists', 'filter') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(),
]);
}
// ── Shorts-only browse ────────────────────────────────────
if ($filter === 'shorts') {
$videos = Video::public()->shorts()->latest()->limit(60)->get();
return view('videos.index', compact('videos', 'filter') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── Sports/Matches-only browse ────────────────────────────
if ($filter === 'match') {
$videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get();
return view('videos.index', compact('videos', 'filter') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── Filtered single-type views ────────────────────────────
if (in_array($filter, ['music', 'latest'])) {
$query = Video::public()->notShorts();
if ($filter === 'music') $query->where('type', 'music');
else $query->latest();
$videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── All (home) — mixed chronological feed ─────────────────
$videos = Video::public()->latest()->limit(60)->get();
$playlistQuery = Playlist::withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()->limit(40);
$playlistQuery->where('visibility', 'public');
$playlists = $playlistQuery->where('is_default', false)->get()
->filter(fn($pl) => $pl->videos_count > 0)
->values();
// Tag each item with its kind so the view can pick the right card
$feedItems = $videos->map(fn($v) => ['kind' => 'video', 'item' => $v, 'date' => $v->created_at])
->concat($playlists->map(fn($p) => ['kind' => 'playlist', 'item' => $p, 'date' => $p->created_at]))
->sortByDesc('date')
->values();
return view('videos.index', compact('feedItems', 'filter') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
public function search(Request $request)
{
$query = $request->get('q', '');
if (empty($query)) {
return redirect()->route('videos.index');
}
$matchCondition = function ($q) use ($query) {
$q->where('title', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%");
};
$allVideos = Video::public()->where($matchCondition)->latest()->get();
$videos = $allVideos->where('is_shorts', false)->values();
$shorts = $allVideos->where('is_shorts', true)->values();
$playlistQuery = Playlist::where('name', 'like', "%{$query}%")
->withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(12);
$playlistQuery->where('visibility', 'public');
$playlists = $playlistQuery->get();
return view('videos.index', compact('videos', 'shorts', 'query', 'playlists'));
}
public function create()
{
return view('videos.create');
}
public function store(Request $request)
{
try {
$audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
$uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
$isAudioUpload = in_array($uploadedExt, $audioExtensions);
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'video' => $isAudioUpload
? 'required|file'
: 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv',
'thumbnail' => $isAudioUpload
? 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480'
: 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'slides' => $isAudioUpload ? 'required|array|min:1' : 'nullable',
'slides.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'primary_language' => 'nullable|string|max:10',
'extra_track_files' => 'nullable|array',
'extra_track_files.*' => 'file',
'extra_track_languages' => 'nullable|array',
'extra_track_languages.*' => 'nullable|string|max:10',
'extra_track_titles' => 'nullable|array',
'extra_track_titles.*' => 'nullable|string|max:255',
'extra_track_descriptions' => 'nullable|array',
'extra_track_descriptions.*'=> 'nullable|string',
]);
$videoFile = $request->file('video');
$filename = self::generateFilename($videoFile->getClientOriginalExtension());
$path = $videoFile->storeAs('public/videos', $filename);
// Get file info
$fileSize = $videoFile->getSize();
$mimeType = $videoFile->getMimeType();
$thumbnailPath = null;
$slideFiles = [];
if ($isAudioUpload && $request->hasFile('slides')) {
// Audio upload: save all slides; first slide becomes the thumbnail
foreach ($request->file('slides') as $slideFile) {
$fname = self::generateFilename($slideFile->getClientOriginalExtension());
$slideFile->storeAs('public/thumbnails', $fname);
$slideFiles[] = $fname;
}
$thumbnailPath = 'public/thumbnails/' . $slideFiles[0];
} elseif ($request->hasFile('thumbnail')) {
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
} elseif (! $isAudioUpload) {
try {
$ffmpeg = FFMpeg::create();
$videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) {
$video = $ffmpeg->open($videoPath);
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
$thumbFilename = Str::uuid().'.jpg';
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
if (! file_exists(storage_path('app/public/thumbnails'))) {
mkdir(storage_path('app/public/thumbnails'), 0755, true);
}
$frame->save($thumbFullPath);
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
}
} catch (\Exception $e) {
\Log::error('FFmpeg thumbnail error: '.$e->getMessage());
}
}
$width = null;
$height = null;
$orientation = 'landscape';
if (! $isAudioUpload) {
try {
$ffprobe = FFProbe::create();
$videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) {
$streams = $ffprobe->streams($videoPath);
$videoStream = $streams->videos()->first();
if ($videoStream) {
$width = $videoStream->get('width');
$height = $videoStream->get('height');
if ($width && $height) {
if ($height > $width) $orientation = 'portrait';
elseif ($width > $height) $orientation = 'landscape';
else $orientation = 'square';
}
}
}
} catch (\Exception $e) {
\Log::error('FFprobe error: '.$e->getMessage());
}
}
$video = Video::create([
'user_id' => Auth::id(),
'title' => $request->title,
'description' => \App\Support\HtmlSanitizer::clean($request->description),
'filename' => $filename,
'path' => $path,
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
'size' => $fileSize,
'mime_type' => $mimeType,
'orientation' => $orientation,
'width' => $width,
'height' => $height,
'status' => $isAudioUpload ? 'ready' : 'processing',
'visibility' => $request->visibility ?? 'public',
'type' => $isAudioUpload ? 'music' : ($request->type ?? 'generic'),
'download_access' => $request->input('download_access', 'disabled'),
'share_token' => Str::random(32),
'language' => $request->input('primary_language') ?: null,
]);
// Save individual slide records for audio uploads with multiple images
foreach ($slideFiles as $position => $fname) {
VideoSlide::create([
'video_id' => $video->id,
'filename' => $fname,
'position' => $position,
]);
}
$nas = app(\App\Services\NasSyncService::class);
$nasUploadSucceeded = false;
if ($nas->isEnabled()) {
// ── NAS-primary: push directly to NAS, delete local temp files ──
try {
$video->load('slides');
// Build slide abs-paths map for audio uploads
$slideAbsPaths = [];
foreach ($slideFiles as $pos => $fname) {
$slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
}
$tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
// For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
if ($isAudioUpload) $tempThumbAbs = null;
$nas->uploadDirectToNas(
$video,
storage_path('app/' . $path),
$tempThumbAbs,
$slideAbsPaths
);
$video->refresh();
$nasUploadSucceeded = true;
} catch (\Throwable $e) {
\Log::error('uploadDirectToNas failed (falling back to local): ' . $e->getMessage());
// NAS went down mid-upload — organise the surviving local files below
}
}
if ($nasUploadSucceeded) {
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
if (! $isAudioUpload) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
} else {
// ── Local storage: move into NAS-mirrored local directory schema ──
try {
$video->load('slides');
$nas->organizeLocalFiles($video);
$video->refresh();
} catch (\Throwable $e) {
\Log::error('organizeLocalFiles failed: ' . $e->getMessage());
}
// Compress + HLS pipeline for local storage
if (! $isAudioUpload) {
CompressVideoJob::dispatch($video)
->onQueue('video-processing')
->onConnection('database');
}
}
// ── Extra language audio tracks ───────────────────────────────────────
if ($isAudioUpload && $request->hasFile('extra_track_files')) {
$nas = app(\App\Services\NasSyncService::class);
$trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
// Create a placeholder record to get the DB ID for the filename
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$videoDir = $nas->resolveVideoDir($video);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => $trackName,
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
// Fall back to local storage
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
$video->load('user');
$userEmail = Auth::user()->email;
$userName = Auth::user()->name;
$uploader = Auth::user();
$subscribers = $uploader->subscribers()->get();
$subscriberEmails = $subscribers->pluck('email')->toArray();
// In-app DB notifications (fast — just inserts, no network)
if ($video->visibility === 'public') {
foreach ($subscribers as $subscriber) {
try {
$subscriber->notify(new NewVideoUploadedNotification($video, $uploader));
} catch (\Throwable $e) {
\Log::error('In-app notification error: '.$e->getMessage());
}
}
}
app()->terminating(function () use ($video, $userEmail, $userName, $uploader, $subscriberEmails) {
// Confirm upload to the uploader
try {
Mail::to($userEmail)->send(new VideoUploaded($video, $userName));
} catch (\Throwable $e) {
\Log::error('Email error: '.$e->getMessage());
}
// Email subscribers (only for public videos)
if ($video->visibility === 'public' && count($subscriberEmails) > 0) {
foreach ($subscriberEmails as $email) {
try {
Mail::to($email)->send(new NewVideoNotification($video, $uploader));
} catch (\Throwable $e) {
\Log::error('Subscriber notification error: '.$e->getMessage());
}
}
}
});
AuditLog::record('video.uploaded', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $video->title,
'details' => ['type' => $video->type, 'visibility' => $video->visibility],
]);
return response()->json([
'success' => true,
'video_id' => $video->id,
'redirect' => route('videos.show', $video),
]);
} catch (\Throwable $e) {
\Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
}
public function showByToken(Request $request, string $token)
{
$video = Video::where('share_token', $token)->firstOrFail();
if (! $video->canView(Auth::user())) {
abort(404);
}
return $this->show($request, $video);
}
public function show(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
$message = $video->isPrivate()
? 'This video is private.'
: 'This video is no longer available.';
return redirect('/')->with('toast_error', $message);
}
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
// Persistent client-side device ID (set in the response cookie below).
// Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
$viewDid = $request->cookie('_did') ?: (string) Str::uuid();
// Device fingerprint hash (set client-side by /fp.js after first paint).
// Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
// Null on the very first visit; the JS will call /identify to backfill it.
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
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'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
} else {
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
})
->exists();
if (! $exists) {
\DB::table('video_views')->insert([
'user_id' => null,
'video_id' => $video->id,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
}
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);
// Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
// tags and <title> reflect that language track (so a shared English version shows
// the English title, not the primary). Falls back to the primary when unset.
$shareTitle = $video->title;
$shareDescription = $video->description;
if ($shareTrackId = (int) $request->input('track', 0)) {
$shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
if ($shareTrack) {
if (! empty($shareTrack->title)) $shareTitle = $shareTrack->title;
if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
}
}
$playlist = null;
$nextVideo = null;
$previousVideo = null;
$playlistVideos = null;
$playlistParam = $request->query('playlist');
if ($playlistParam) {
$playlist = Playlist::where('share_token', $playlistParam)->first();
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
$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',
};
// Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $viewDid, 60 * 24 * 365 * 5));
}
public function playerData(Video $video, Request $request)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$coverUrl = $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
$slides = $video->slides->count() > 1
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'flag' => $allLangData[$t->language]['flag'] ?? null,
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all();
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
'description' => $video->description ?? '',
'download_url' => route('videos.downloadMp3', $video),
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
]);
}
public function matchData(Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
return response()->json([
'success' => true,
'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
]);
}
public function edit(Video $video, Request $request)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (! $request->expectsJson() && ! $request->ajax()) {
return view('videos.edit', compact('video'));
}
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
'id' => $s->id,
'url' => $s->url,
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]),
])->values();
return response()->json([
'success' => true,
'video' => [
'id' => $video->id,
'title' => $video->title,
'description' => $video->description,
'thumbnail' => $video->thumbnail,
'thumbnail_url' => $video->thumbnail_url,
'visibility' => $video->visibility ?? 'public',
'type' => $video->type ?? 'generic',
'download_access' => $video->download_access,
'is_audio' => $this->isAudioOnlyFile($video),
'slides' => $slides,
'language' => $video->language,
'audio_tracks' => $audioTracks,
],
]);
}
public function update(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'slides_add' => 'nullable|array',
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'primary_language' => 'nullable|string|max:10',
'extra_track_files' => 'nullable|array',
'extra_track_files.*' => 'file',
'extra_track_languages' => 'nullable|array',
'extra_track_languages.*' => 'nullable|string|max:10',
'extra_track_titles' => 'nullable|array',
'extra_track_titles.*'=> 'nullable|string|max:255',
'track_title_updates' => 'nullable|array',
'track_title_updates.*' => 'nullable|string|max:255',
'track_description_updates' => 'nullable|array',
'track_description_updates.*' => 'nullable|string',
'track_language_updates' => 'nullable|array',
'track_language_updates.*' => 'nullable|string|max:10',
'track_file_updates' => 'nullable|array',
'track_file_updates.*' => 'nullable|file',
'promote_track_id' => 'nullable|integer',
'delete_track_ids' => 'nullable|array',
'delete_track_ids.*' => 'integer',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
if (array_key_exists('description', $data)) {
$data['description'] = \App\Support\HtmlSanitizer::clean($data['description']);
}
$data['download_access'] = $request->input('download_access', 'disabled');
if ($request->has('primary_language')) {
$data['language'] = $request->input('primary_language') ?: null;
}
if ($request->hasFile('thumbnail')) {
if ($video->thumbnail) {
Storage::delete($video->thumbnailStorageKey());
}
$nas = app(\App\Services\NasSyncService::class);
$ext = strtolower($request->file('thumbnail')->getClientOriginalExtension() ?: 'jpg');
if ($nas->isEnabled()) {
// Push directly to NAS — use video root dir (handles promoted-track paths too)
$nasDir = $this->nasVideoDir($video, $nas);
$tempPath = $request->file('thumbnail')->storeAs('public/tmp', "thumb_{$video->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nas->mkdirp($nasDir);
if ($nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}")) {
@unlink($tempAbs);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
} else {
// NAS push failed — fall back to local (auto-sync will retry)
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
rename($tempAbs, "{$localDir}/thumb.{$ext}");
$userSlug = $nas->userSlug($video->user);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
} else {
// NAS disabled — keep on local disk
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
$userSlug = $nas->userSlug($video->user);
$nasDir = $this->nasVideoDir($video, $nas);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
}
if (! isset($data['visibility'])) {
unset($data['visibility']);
}
// Handle slide reorder / removal / additions for audio tracks
$slidesChanged = false;
if ($this->isAudioOnlyFile($video)) {
// slides_order is a JSON array of kept slide IDs in their new order
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
// Delete slides not in the kept list
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled() && str_starts_with($slide->filename, 'users/')) {
try { $nas->deleteFile($slide->filename); } catch (\Throwable $e) {}
}
Storage::delete($slide->storageKey());
$slide->delete();
$slidesChanged = true;
});
// Reorder kept slides
foreach ($keptOrder as $pos => $slideId) {
VideoSlide::where('id', $slideId)->where('video_id', $video->id)
->update(['position' => $pos]);
}
if (! empty($keptOrder)) $slidesChanged = true;
// Add new slides
if ($request->hasFile('slides_add')) {
$nextPos = count($keptOrder);
$nasForSlides = app(\App\Services\NasSyncService::class);
$nasEnabled = $nasForSlides->isEnabled();
// Derive NAS dir without scanning when possible
$nasDir = $nasEnabled ? $this->nasVideoDir($video, $nasForSlides) : null;
// Local dir used as fallback or when NAS is off
$localDir = $nasForSlides->localVideoDir($video);
$userSlug = $nasForSlides->userSlug($video->user);
$relBase = $this->nasVideoDir($video, $nasForSlides);
if ($nasEnabled) {
$nasForSlides->mkdirp("{$nasDir}/slides");
} else {
@mkdir("{$localDir}/slides", 0755, true);
}
foreach ($request->file('slides_add') as $file) {
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
if ($nasEnabled) {
// Push directly to NAS using position-based filename (matches MediaController)
$nasSlide = "{$nasDir}/slides/{$nextPos}.{$ext}";
$tempPath = $file->storeAs('public/tmp', "slide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasForSlides->putFile($tempAbs, $nasSlide)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasSlide]);
} else {
// NAS push failed — save locally, auto-sync will retry
@mkdir("{$localDir}/slides", 0755, true);
rename($tempAbs, "{$localDir}/slides/{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
}
} else {
// NAS off — store in local project folder
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
}
$nextPos++;
$slidesChanged = true;
}
}
// Keep thumbnail in sync with the first slide
$firstSlide = $video->slides()->orderBy('position')->first();
if ($firstSlide) {
$data['thumbnail'] = $firstSlide->filename;
}
// Invalidate cached slideshow videos (plain + visualizer) whenever slides change
if ($slidesChanged) {
// Slides feed every rendered variant (all tracks + visualizer) — wipe them all.
$cacheDir = dirname(storage_path('app/' . $this->slideshowRel($video, false, 0)));
foreach (glob($cacheDir . '/video*.mp4') ?: [] as $f) @unlink($f);
$data['slideshow_video_path'] = null;
}
}
// ── Audio track management (delete + add) ────────────────────────────
if ($this->isAudioOnlyFile($video)) {
$nas = app(\App\Services\NasSyncService::class);
// Delete tracks requested for removal
$deleteIds = $request->input('delete_track_ids', []);
if (! empty($deleteIds)) {
$video->audioTracks()->whereIn('id', $deleteIds)->each(function ($track) use ($nas) {
if ($track->path !== '__pending__') {
if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
} else {
@unlink($track->localPath());
}
}
$track->delete();
});
}
// Update titles, descriptions, and languages of existing tracks
$titleUpdates = $request->input('track_title_updates', []);
$descUpdates = $request->input('track_description_updates', []);
$languageUpdates = $request->input('track_language_updates', []);
$allTrackIds = array_unique(array_merge(
array_keys($titleUpdates), array_keys($descUpdates), array_keys($languageUpdates)
));
foreach ($allTrackIds as $trackId) {
$fields = [];
if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $descUpdates)) $fields['description'] = \App\Support\HtmlSanitizer::clean($descUpdates[$trackId]) ?: null;
if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) {
$fields['language'] = $languageUpdates[$trackId];
$fields['label'] = strtoupper($languageUpdates[$trackId]);
}
if ($fields) $video->audioTracks()->where('id', (int) $trackId)->update($fields);
}
// Replace audio files for existing tracks
if ($request->hasFile('track_file_updates')) {
foreach ($request->file('track_file_updates') as $trackId => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$track = $video->audioTracks()->find((int) $trackId);
if (! $track) continue;
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
}
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage());
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
// Upload new extra tracks
if ($request->hasFile('extra_track_files')) {
$trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
}
// Promote a secondary track to primary (swap metadata)
if ($promoteId = (int) $request->input('promote_track_id')) {
$promoteTrack = $video->audioTracks()->find($promoteId);
if ($promoteTrack) {
\Log::info('Track promote: swapping primary ↔ secondary', [
'video_id' => $video->id,
'old_primary' => ['lang' => $video->language, 'path' => $video->path, 'filename' => $video->filename],
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
]);
// Create new secondary track from current primary metadata
VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $video->language ?? 'en',
'label' => strtoupper($video->language ?? 'en'),
'title' => $video->title,
'description' => $video->description,
'path' => $video->path,
'filename' => $video->filename,
]);
// Override $data with the promoted track's values
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
$data['language'] = $promoteTrack->language;
$data['description'] = $promoteTrack->description ?: ($data['description'] ?? $video->description);
$data['path'] = $promoteTrack->path;
$data['filename'] = $promoteTrack->filename;
$promoteTrack->delete();
} else {
\Log::warning('Track promote: promote_track_id not found', ['video_id' => $video->id, 'promote_track_id' => $promoteId]);
}
}
$video->update($data);
// If the title changed, rename the NAS folder and update meta.json.
// We do NOT dispatch NasSyncVideoJob for regular edits — thumbnails and
// slides are already pushed to NAS above, so there is nothing left to sync.
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
try {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->renameVideoDir($video->fresh());
$nas->syncVideoMeta($video->fresh());
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS video dir rename/meta failed: ' . $e->getMessage());
}
}
AuditLog::record('video.updated', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $video->title,
]);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Video updated successfully!',
'video' => [
'id' => $video->id,
'title' => $video->title,
'description' => $video->description,
'visibility' => $video->visibility,
'download_access' => $video->download_access,
],
]);
}
return redirect()->route('videos.show', $video)->with('success', 'Video updated!');
}
public function destroy(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
// If the owner has 2FA enabled, require a valid OTP
$user = Auth::user();
if ($user->two_factor_enabled && $user->two_factor_secret) {
$code = $request->header('X-2FA-Code') ?? $request->input('otp_code', '');
$google2fa = app('pragmarx.google2fa');
if (! $google2fa->verifyKey(decrypt($user->two_factor_secret), (string) $code)) {
return response()->json(['message' => 'Invalid 2FA code. Please try again.'], 422);
}
}
AuditLog::record('video.deleted', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $video->title,
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
]);
Storage::delete($video->path);
if ($video->thumbnail) {
Storage::delete($video->thumbnailStorageKey());
}
$nasSync = app(\App\Services\NasSyncService::class);
if ($nasSync->isEnabled()) {
$nasSync->deleteVideo($video);
}
$video->delete();
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Video deleted successfully!',
]);
}
return redirect()->route('videos.index')->with('success', 'Video deleted!');
}
// ─────────────────────────────────────────────────────────────────────────
// Replace media file (keeps all metadata, views, likes, comments intact)
// ─────────────────────────────────────────────────────────────────────────
// Replace media file (keeps all metadata, views, likes, comments intact)
// ─────────────────────────────────────────────────────────────────────────
public function replaceFile(Request $request, Video $video)
{
$user = Auth::user();
if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) {
abort(403);
}
$request->validate([
'replacement_file' => [
'required',
'file',
'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
],
]);
$newFile = $request->file('replacement_file');
$mimeType = $newFile->getMimeType();
$isAudio = str_starts_with($mimeType, 'audio/');
$newSize = $newFile->getSize();
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class);
// ── 1. Clear old HLS ─────────────────────────────────────────────────
if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path);
}
// ── 2. Delete old media file ─────────────────────────────────────────
// NAS: only delete the video file; thumbnail/slides/meta.json stay
// Local: unlink the local copy
if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
try { $nas->deleteFile($video->path); } catch (\Throwable) {}
} else {
$oldLocal = storage_path('app/' . $video->path);
if (file_exists($oldLocal)) @unlink($oldLocal);
}
// ── 3. Store new file to a temporary local path ──────────────────────
$tempFilename = \Str::uuid() . '.' . $newExt;
$tempRelPath = 'public/videos/' . $tempFilename;
$newFile->storeAs('public/videos', $tempFilename);
$tempAbsPath = storage_path('app/' . $tempRelPath);
if (! file_exists($tempAbsPath)) {
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
}
// ── 4. Extract metadata via FFprobe ──────────────────────────────────
$width = $height = null;
$orientation = 'landscape';
$duration = 0;
try {
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
$out = [];
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
$duration = (int) round((float) ($out[0] ?? 0));
if (! $isAudio) {
$ffprobe = \FFMpeg\FFProbe::create();
$stream = $ffprobe->streams($tempAbsPath)->videos()->first();
if ($stream) {
$width = $stream->get('width');
$height = $stream->get('height');
if ($width && $height) {
if ($height > $width) $orientation = 'portrait';
elseif ($width > $height) $orientation = 'landscape';
else $orientation = 'square';
}
}
}
} catch (\Throwable $e) {
\Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
}
// ── 5. Persist the file to its final location ─────────────────────────
//
// NAS path: push directly to NAS using uploadDirectToNas()
// This handles legacy paths correctly by computing the
// proper users/.../videos/... directory.
// uploadDirectToNas() updates path/filename in DB.
//
// Local path: update path/filename to temp location, then call
// organizeLocalFiles() which moves it to users/... layout.
// CompressVideoJob() sets status=ready and chains HLS.
$nasReplaceSucceeded = false;
if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext)
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
try {
// Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh();
$nasReplaceSucceeded = true;
} catch (\Throwable $e) {
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
// Temp file still exists — fall through to organizeLocalFiles below
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
}
}
if ($nasReplaceSucceeded) {
$metaUpdates = [
'size' => $newSize,
'mime_type'=> $mimeType,
'has_hls' => false,
'hls_path' => null,
// For NAS the upload is the "done" state — set ready so GenerateHlsJob runs
'status' => 'ready',
];
if (! $isAudio) {
$metaUpdates['duration'] = $duration ?: $video->duration;
$metaUpdates['width'] = $width ?: $video->width;
$metaUpdates['height'] = $height ?: $video->height;
$metaUpdates['orientation'] = $orientation;
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
}
$video->update($metaUpdates);
if (! $isAudio) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
} else {
// NAS unavailable — keep file locally; NAS-mirrored layout
try {
$nas->organizeLocalFiles($video);
$video->refresh();
} catch (\Throwable $e) {
\Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
}
$metaUpdates = [
'size' => $newSize,
'mime_type'=> $mimeType,
'has_hls' => false,
'hls_path' => null,
'status' => $isAudio ? 'ready' : 'processing',
];
if (! $isAudio) {
$metaUpdates['duration'] = $duration ?: $video->duration;
$metaUpdates['width'] = $width ?: $video->width;
$metaUpdates['height'] = $height ?: $video->height;
$metaUpdates['orientation'] = $orientation;
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
}
$video->update($metaUpdates);
if (! $isAudio) {
\App\Jobs\CompressVideoJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
}
return response()->json([
'success' => true,
'message' => $isAudio
? 'Audio file replaced successfully.'
: 'File replaced — re-encoding has started. The video will be ready shortly.',
'status' => $video->fresh()->status,
'is_audio' => $isAudio,
]);
}
public function trending(Request $request)
{
$hours = $request->get('hours', 48);
$limit = $request->get('limit', 50);
$hours = min(max($hours, 24), 168);
$limit = min(max($limit, 10), 100);
$videos = Video::public()
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->with('user')
->get();
$videos = $videos->map(function ($video) use ($hours) {
$recentViews = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subHours($hours))
->count();
$likeCount = \DB::table('video_likes')
->where('video_id', $video->id)
->count();
$ageHours = $video->created_at->diffInHours(now());
$velocity = $recentViews / $hours;
$recencyBonus = max(0, 1 - ($ageHours / 240));
$score = ($recentViews * 0.70) +
($velocity * 100 * 0.15) +
($recencyBonus * 50 * 0.10) +
($likeCount * 0.1 * 0.05);
$video->trending_score = round($score, 2);
$video->view_count = $recentViews;
$video->like_count = $likeCount;
return $video;
});
$trendingVideos = $videos
->filter(fn ($v) => $v->trending_score > 0)
->sortByDesc('trending_score')
->take($limit)
->values();
return view('videos.trending', [
'videos' => $trendingVideos,
'hours' => $hours,
'limit' => $limit,
]);
}
public function shorts(Request $request)
{
$videos = Video::public()
->where('is_shorts', true)
->where('status', 'ready')
->with('user')
->latest()
->get();
return view('videos.shorts', compact('videos'));
}
public function stream(Video $video)
{
if (! $video->canView(Auth::user())) {
abort(404, 'Video not found');
}
$path = $video->localVideoPath();
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found');
}
}
$fileSize = filesize($path);
$mimeType = $video->mime_type ?: 'video/mp4';
$handle = fopen($path, 'rb');
if (! $handle) {
abort(500, 'Cannot open video file');
}
$range = request()->header('Range');
if ($range) {
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
$start = intval($matches[1] ?? 0);
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Type: '.$mimeType);
header('Content-Length: '.$length);
header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fseek($handle, $start);
$chunkSize = 8192;
$bytesToRead = $length;
while (! feof($handle) && $bytesToRead > 0) {
$buffer = fread($handle, min($chunkSize, $bytesToRead));
echo $buffer;
flush();
$bytesToRead -= strlen($buffer);
}
fclose($handle);
exit;
} else {
header('Content-Type: '.$mimeType);
header('Content-Length: '.$fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fpassthru($handle);
fclose($handle);
exit;
}
}
public function streamAudioTrack(Video $video, VideoAudioTrack $track)
{
if ($track->video_id !== $video->id) {
abort(404);
}
if (! $video->canView(Auth::user())) {
abort(404);
}
$localPath = $track->localPath();
if (! file_exists($localPath)) {
// Use the same robust fallback chain as the primary stream():
// local path → nas_cache → NAS download. This is what lets a demoted
// secondary track (e.g. the old primary after a language swap) keep
// playing even when its file only exists in the stream cache.
$nas = app(\App\Services\NasSyncService::class);
$resolved = $nas->ensureLocalTrackCopy($track);
if (! $resolved) {
\Log::warning('streamAudioTrack: file not found', [
'video_id' => $video->id,
'track_id' => $track->id,
'language' => $track->language,
'path' => $track->path,
'filename' => $track->filename,
'nas_enabled' => $nas->isEnabled(),
]);
abort(404, 'Audio track file not found');
}
$localPath = $resolved;
}
$fileSize = filesize($localPath);
$ext = strtolower(pathinfo($track->filename, PATHINFO_EXTENSION));
$mimeMap = ['mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac',
'flac' => 'audio/flac', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg'];
$mimeType = $mimeMap[$ext] ?? 'audio/mpeg';
$handle = fopen($localPath, 'rb');
if (! $handle) abort(500, 'Cannot open audio track');
$range = request()->header('Range');
if ($range) {
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
$start = intval($matches[1] ?? 0);
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $length);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fseek($handle, $start);
$remaining = $length;
while (! feof($handle) && $remaining > 0) {
$buf = fread($handle, min(8192, $remaining));
echo $buf;
flush();
$remaining -= strlen($buf);
}
fclose($handle);
exit;
}
if (request()->boolean('download')) {
$downloadName = \Str::slug($video->title) . '-' . strtolower($track->label ?: $track->language) . '.' . $ext;
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
}
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fpassthru($handle);
fclose($handle);
exit;
}
public function deleteAudioTrack(Request $request, Video $video, VideoAudioTrack $track)
{
if ($track->video_id !== $video->id) abort(404);
if (Auth::id() !== $video->user_id) abort(403);
$nas = app(\App\Services\NasSyncService::class);
if ($track->path !== '__pending__') {
if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
} else {
@unlink($track->localPath());
}
}
$track->delete();
return response()->json(['success' => true]);
}
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{
try {
// All tracks (primary + secondary) live directly in the song's own folder —
// never a separate tracks/ subfolder — with a unique, lowercase name.
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
$base = basename($localDir);
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
$trackFile->move($localDir, $trackName);
$userSlug = $nas->userSlug($video->user);
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
$track->update(['path' => $relPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
* overwrite each other even when they share a language.
*/
private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
public function hls(Video $video, $file = 'playlist.m3u8')
{
if (! $video->canView(Auth::user())) {
abort(404);
}
if (! $video->has_hls) {
abort(404, 'HLS unavailable');
}
$hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);
if (! file_exists($hlsPath)) {
abort(404);
}
$mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
? 'application/vnd.apple.mpegurl'
: 'video/mp2t';
header('Content-Type: '.$mimeType);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Range');
if (request()->header('Range')) {
$size = filesize($hlsPath);
preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches);
$start = intval($matches[1] ?? 0);
$end = $matches[2] ? intval($matches[2]) : $size - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Length: '.$length);
header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);
$handle = fopen($hlsPath, 'rb');
fseek($handle, $start);
echo fread($handle, $length);
fclose($handle);
} else {
header('Content-Length: '.filesize($hlsPath));
readfile($hlsPath);
}
exit;
}
private function checkDownloadAccess(Video $video): void
{
$access = $video->download_access ?? 'disabled';
$user = Auth::user();
match ($access) {
'disabled' => abort(403, 'Downloads are not enabled for this video.'),
'registered' => $user ? null : abort(403, 'You must be logged in to download this video.'),
'subscribers' => ($user && ($user->id === $video->user_id || $user->isSubscribedTo($video->user)))
? null
: abort(403, 'You must be subscribed to this channel to download this video.'),
default => null, // 'everyone' — allow
};
}
public function download(Video $video)
{
$this->checkDownloadAccess($video);
$this->recordDownload($video, 'video');
$path = $video->localVideoPath();
// If not on local disk, try to pull from NAS
if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
// Already a video — serve directly, no conversion needed
if (! $this->isAudioOnlyFile($video)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
return response()->download($path, $this->safeFilename($video->title, 'video') . '.' . $ext, [
'Content-Type' => $video->mime_type ?: 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, $ext),
]);
}
// Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
// Any non-primary or visualizer variant is served straight off disk (no DB column).
if ($viz || $trackId) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// Not generated yet — let the player trigger background generation.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
// Primary plain — DB column must confirm it's the current version.
if ($video->slideshow_video_path) {
$slideshowCache = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// DB says cached but file is gone — clear the stale column
$video->update(['slideshow_video_path' => null]);
}
// Not cached — redirect to the video page which will auto-trigger the
// background generation progress bar (startSlideshowDownload). This avoids
// blocking the HTTP worker with a synchronous FFmpeg encode.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
/**
* Extract up to three dominant, saturated colours from a cover image — the same
* algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
* so the baked-in download bars match what viewers see on the page. Returns three
* lowercase hex strings ordered left → middle → right of the bar gradient; falls
* back to light greys when nothing usable is found.
*/
private function slideVisualizerColors(?string $imagePath): array
{
$default = ['ffffff', 'c8c8c8', 'aaaaaa'];
if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
return $default;
}
$raw = @file_get_contents($imagePath);
$img = $raw ? @imagecreatefromstring($raw) : false;
if (! $img) return $default;
$t = imagecreatetruecolor(24, 24);
imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
imagedestroy($img);
$buckets = [];
for ($y = 0; $y < 24; $y++) {
for ($x = 0; $x < 24; $x++) {
$p = imagecolorat($t, $x, $y);
$r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
$bright = ($r + $g + $b) / 3;
if ($bright < 25 || $bright > 230) continue; // skip near-black / near-white
$mx = max($r, $g, $b); $mn = min($r, $g, $b);
if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue; // skip low-saturation
$k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
$buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
}
}
imagedestroy($t);
if (! $buckets) return $default;
usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
$avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];
// Most-common colour first, then the next colours that are visually distinct from it.
$chosen = [$avg($buckets[0])];
for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
$c = $avg($buckets[$i]); $far = true;
foreach ($chosen as $e) {
if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
}
if ($far) $chosen[] = $c;
}
while (count($chosen) < 3) $chosen[] = $chosen[0];
return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
}
// ── Background slideshow generation + progress polling ────────
public function slideshowGenerate(Request $request, Video $video)
{
$this->checkDownloadAccess($video);
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Not an audio file'], 422);
}
// Version-aware: render the video from the audio the viewer is playing — the
// primary track, or a specific language track (?track={id}). The optional
// visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
// Audio source = the version being played.
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$audioPath = $track->localPath();
if (! file_exists($audioPath) && $nas->isEnabled()) {
$audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
}
} else {
$audioPath = $video->localVideoPath();
}
if (! file_exists($audioPath)) {
return response()->json(['error' => 'Audio file not found'], 404);
}
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . $suffix . '.txt';
// Already cached. Only the primary plain video is tracked by the DB column; every
// other variant (a language track and/or visualizer) is guarded by file existence.
// All are invalidated together when the slides are edited (see update()).
$usesDbColumn = (! $viz && ! $trackId);
$cached = $usesDbColumn
? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
: (file_exists($outPath) && filesize($outPath) > 0);
if ($cached) {
return response()->json(['status' => 'ready']);
}
// Stale file on disk (e.g. slides were edited) — delete it before regenerating
if (file_exists($outPath)) {
@unlink($outPath);
}
// Already running — return so the frontend keeps polling
if (file_exists($pidFile)) {
$pid = (int) trim(file_get_contents($pidFile));
if ($pid > 0 && file_exists("/proc/{$pid}")) {
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
. escapeshellarg($audioPath) . ' 2>/dev/null', $dO);
return response()->json(['status' => 'running', 'duration' => (float) trim($dO[0] ?? '0')]);
}
}
// Probe duration
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
. escapeshellarg($audioPath) . ' 2>/dev/null', $durOut);
$dur = isset($durOut[0]) ? (float) trim($durOut[0]) : null;
if (! $dur || $dur < 1) {
return response()->json(['error' => 'Cannot probe audio duration'], 500);
}
$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.
// A single slide is normally a still image, but with the visualizer overlay the
// bars animate — so it must not be encoded with -tune stillimage.
$isStillImage = ($validSlides->count() === 1) && ! $viz;
$vFlags = $this->ffmpegVideoFlags($isStillImage);
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
if ($viz) {
// ── Visualizer build ─────────────────────────────────────────────────
// Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
// multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
// this run below real-time; the loop filter makes it ~25x real-time instead.
// overlay shortest=1 bounds the otherwise-endless looped background to the audio.
$rate = 20;
$inputs = '';
$fcParts = [];
if ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
foreach ($validSlides as $i => $slide) {
$inputs .= ' -i ' . escapeshellarg($slide->localPath());
// fps MUST come last so each branch is constant-frame-rate for xfade.
$fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
. number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = $n;
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
$fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
} elseif ($validSlides->count() === 1) {
$inputs .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
} else {
$inputs .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]format=yuv420p[vbase]";
}
// Frequency bars matched to the in-player visualizer:
// • equal-width bars → fscale=lin (log made the left bars wide, right thin)
// • cover colours → render white bars (a shape+alpha mask), then tint
// them with a horizontal gradient of the cover's 3 dominant colours
// (left → middle → right), exactly like the page's canvas bars
// • translucent overlay → showfreqs already outputs a transparent backdrop,
// so we just dial the bar alpha down and composite over the artwork.
[$c0, $c1, $c2] = $this->slideVisualizerColors(
$validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
);
$fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
. ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
$fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
. ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
$fcParts[] = "[bars]alphaextract[vmask]";
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts);
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
$inputs = '';
$scaleFc = [];
foreach ($validSlides as $i => $slide) {
$imgPath = $slide->localPath();
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$xfadeFc = [];
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vout]' : "[v{$i}]";
$xfadeFc[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
$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);
// $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
// GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
// on CPU (libx264) and the download keeps working.
if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
$inner = $cmd
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
$bgCmd = 'nohup bash -c ' . escapeshellarg($inner)
. ' 2>' . escapeshellarg($logFile)
. ' & echo $! > ' . escapeshellarg($pidFile);
} else {
$bgCmd = 'nohup ' . $cmd
. ' 2>' . escapeshellarg($logFile)
. ' & echo $! > ' . escapeshellarg($pidFile);
}
exec($bgCmd);
return response()->json(['status' => 'started', 'duration' => $dur]);
}
public function slideshowProgress(Video $video)
{
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
// Read progress content once
$content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
$isDone = strpos($content, 'progress=end') !== false;
// If progress says done OR the output file exists and process is gone
if (! $isDone && file_exists($outPath) && filesize($outPath) > 0) {
$pid = file_exists($pidFile) ? (int) trim(file_get_contents($pidFile)) : 0;
if (! ($pid > 0 && file_exists("/proc/{$pid}"))) {
$isDone = true;
}
}
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
return response()->json(['percent' => 100, 'status' => 'ready']);
}
if (! $content) {
// If a PID was recorded but the process is already gone, generation failed
if (file_exists($pidFile)) {
$pid = (int) trim(file_get_contents($pidFile));
if ($pid > 0 && ! file_exists("/proc/{$pid}")) {
return response()->json(['percent' => 0, 'status' => 'error']);
}
}
return response()->json(['percent' => 0, 'status' => 'waiting']);
}
// Parse the most-recent out_time_ms value from the progress file
preg_match_all('/out_time_ms=(\d+)/', $content, $m);
$outTimeMs = ! empty($m[1]) ? (int) end($m[1]) : 0;
// FFmpeg's out_time_ms field is actually in microseconds despite its name
$totalUs = (float) request()->query('duration', 0) * 1_000_000;
$percent = $totalUs > 0 ? min(99, (int) round(($outTimeMs / $totalUs) * 100)) : 2;
return response()->json(['percent' => $percent, 'status' => 'processing']);
}
public function downloadMp3(Video $video)
{
$this->checkDownloadAccess($video);
$path = $video->localVideoPath();
if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
$slug = $this->safeFilename($video->title, 'audio');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
// Already MP3 — serve directly
if (strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)) === 'mp3') {
$this->recordDownload($video, 'mp3');
return response()->download($path, $this->safeFilename($video->title, 'audio') . '.mp3', [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
]);
}
$isAudio = $this->isAudioOnlyFile($video);
$hwaccel = $this->ffmpegHwaccelFlags(! $isAudio);
$escaped = escapeshellarg($path);
$tmp = sys_get_temp_dir() . '/' . \Str::uuid() . '.mp3';
$cmd = "{$ffmpeg} -y {$hwaccel}-i {$escaped}"
. " -vn -acodec libmp3lame -q:a 2"
. ' ' . escapeshellarg($tmp) . ' 2>/dev/null';
set_time_limit(0);
exec($cmd, $out, $exitCode);
if ($exitCode !== 0 || ! file_exists($tmp) || filesize($tmp) === 0) {
@unlink($tmp);
abort(500, 'Failed to generate MP3 file.');
}
$this->recordDownload($video, 'mp3');
return response()->download($tmp, $this->safeFilename($video->title, 'audio') . '.mp3', [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
])->deleteFileAfterSend(true);
}
private function safeFilename(string $title, string $fallback): string
{
// Strip characters illegal on Windows/Linux/macOS filesystems, then trim
$name = preg_replace('/[\\\\\/:\*\?"<>\|]/', '', $title);
$name = trim($name);
return $name !== '' ? $name : $fallback;
}
private function contentDisposition(string $title, string $ext): string
{
$ascii = \Str::slug($title) ?: 'download';
$safe = $this->safeFilename($title, $ascii);
$utf8 = rawurlencode($safe . '.' . $ext);
return 'attachment; filename="' . $ascii . '.' . $ext . '"; filename*=UTF-8\'\'' . $utf8;
}
private function downloadSlideshowVideo(Video $video, $slides, string $audioPath, string $ffmpeg)
{
if ($video->slideshow_video_path) {
$cached = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($cached) && filesize($cached) > 0) {
return response()->download($cached, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
}
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
/** Full video codec flags for raw FFmpeg shell commands (GPU if enabled, else CPU). */
private function ffmpegVideoFlags(bool $stillImage = false): string
{
return Setting::ffmpegVideoFlags($stillImage);
}
/** CPU-only codec flags — used as fallback when GPU encoding fails. */
private function ffmpegVideoFlagsCpu(bool $stillImage = false): string
{
return Setting::ffmpegVideoFlagsCpu($stillImage);
}
/** hwaccel decode flags — only used when input is a real video file and GPU is on. */
private function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
return Setting::ffmpegHwaccelFlags($inputIsVideo);
}
/**
* Song-folder-relative path (under storage/app) for the generated "Download Video".
* Regenerable renders live in the song's own `cache/` subfolder — separated from the
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '') . '.mp4';
}
/**
* Return the NAS video root directory (where thumb.jpg and slides/ live).
* When the primary file was promoted from a secondary track, video->path points
* into a 'tracks/' subfolder — in that case we go up one extra level.
*/
private function nasVideoDir(Video $video, \App\Services\NasSyncService $nas): string
{
if (str_starts_with($video->path, 'users/')) {
$dir = dirname($video->path);
if (basename($dir) === 'tracks') {
$dir = dirname($dir);
}
return $dir;
}
return $nas->resolveVideoDir($video);
}
private function isAudioOnlyFile(Video $video): bool
{
if ($video->mime_type) {
return str_starts_with($video->mime_type, 'audio/');
}
$audioExts = ['mp3', 'aac', 'm4a', 'ogg', 'wav', 'flac', 'opus', 'wma'];
return in_array(strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)), $audioExts);
}
private function recordDownload(Video $video, string $type): void
{
$request = request();
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$userId = Auth::id();
// Deduplicate: browsers/download managers sometimes retry the connection.
// Skip recording if the same user (or guest IP) already downloaded this
// video+type within the last 10 minutes.
$alreadyRecorded = \DB::table('video_downloads')
->where('video_id', $video->id)
->where('type', $type)
->where('downloaded_at', '>=', now()->subMinutes(10))
->when(
$userId !== null,
fn ($q) => $q->where('user_id', $userId),
fn ($q) => $q->whereNull('user_id')->where('ip_address', $ip)
)
->exists();
if ($alreadyRecorded) {
return;
}
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('video_downloads')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'type' => $type,
'downloaded_at' => now(),
]);
\DB::table('videos')->where('id', $video->id)->increment('download_count');
}
public function recordShare(Video $video)
{
$userId = Auth::id();
// Authenticated users reuse their existing share token for this video
if ($userId) {
$existing = \DB::table('video_shares')
->where('video_id', $video->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('share.access', $existing->token)]);
}
}
// Generate a unique 10-char token
do {
$token = Str::random(10);
} while (\DB::table('video_shares')->where('token', $token)->exists());
\DB::table('video_shares')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('share.access', $token)]);
}
/**
* Email a friend a properly-formatted share email, version-aware: the link and the
* email's title reflect the language track the sender chose. The URL is built
* server-side so outgoing mail can never carry an attacker-supplied link.
*/
public function shareByEmail(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'email' => 'required|email|max:255',
'message' => 'nullable|string|max:500',
'track' => 'nullable|integer',
]);
$trackId = (int) ($data['track'] ?? 0);
$shareTitle = $video->title;
$shareUrl = $video->share_url;
if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) {
if (! empty($track->title)) $shareTitle = $track->title;
$shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId;
}
try {
\Mail::to($data['email'])->send(new \App\Mail\VideoShared(
$video,
$shareUrl,
Auth::user(),
$data['message'] ?? null,
$shareTitle,
));
} catch (\Throwable $e) {
\Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]);
return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500);
}
return response()->json(['success' => true]);
}
/**
* Share a video directly with selected members. Each recipient gets an in-app
* notification (clicking it opens the video) and an email.
*/
public function shareWithMembers(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'user_ids' => 'required|array|min:1|max:30',
'user_ids.*' => 'integer|exists:users,id',
'message' => 'nullable|string|max:500',
]);
$sharer = Auth::user();
$message = $data['message'] ?? null;
$recipients = \App\Models\User::whereIn('id', $data['user_ids'])
->where('id', '!=', $sharer->id)
->get();
foreach ($recipients as $member) {
try {
$member->notify(new \App\Notifications\VideoSharedWithUser(
$video, $sharer, $message, $video->share_url
));
} catch (\Throwable $e) {
\Log::error('Share-to-member failed: ' . $e->getMessage(), [
'video_id' => $video->id, 'member_id' => $member->id,
]);
}
}
return response()->json(['success' => true, 'count' => $recipients->count()]);
}
public function accessShare(Request $request, string $token)
{
$share = \DB::table('video_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$video = Video::find($share->video_id);
if (! $video || ! $video->canView(Auth::user())) {
return redirect('/');
}
// Identify device via persistent cookie; generate one for first-time visitors
$did = $request->cookie('_did') ?: (string) Str::uuid();
// Record only the first access from this device for this share link
$seen = \DB::table('share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name'=> $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'accessed_at' => now(),
]);
}
// Carry the version selector (?track=) through the redirect so the recipient opens
// the exact language the sharer was listening to.
$dest = route('videos.showByToken', $video->share_token);
if ($request->filled('track')) {
$dest .= '?track=' . (int) $request->input('track');
}
return redirect($dest)
->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 trackProgress(Request $request, Video $video)
{
$seconds = max(0, (int) $request->input('watched_seconds', 0));
$completed = (bool) $request->boolean('completed');
$viewDid = $request->cookie('_did');
$query = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDay())
->orderByDesc('id')
->limit(1);
if (Auth::check()) {
$query->where('user_id', Auth::id());
} else {
$query->whereNull('user_id');
if ($viewDid) {
$query->where('device_id', $viewDid);
} else {
return response()->json(['ok' => false], 204);
}
}
$view = $query->first();
if (! $view) {
return response()->json(['ok' => false], 204);
}
$updates = [];
if ($seconds > (int) $view->watched_seconds) {
$updates['watched_seconds'] = $seconds;
}
if ($completed && ! $view->completed) {
$updates['completed'] = true;
}
if ($updates) {
\DB::table('video_views')->where('id', $view->id)->update($updates);
}
return response()->json(['ok' => true]);
}
public function insights(Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$id = $video->id;
$now = now();
$totalViews = \DB::table('video_views')->where('video_id', $id)->count();
$uniqueViewers = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$viewsToday = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->startOfDay())
->count();
$viewsThisWeek = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->subDays(7))
->count();
$viewsLastWeek = \DB::table('video_views')
->where('video_id', $id)
->whereBetween('watched_at', [
$now->copy()->subDays(14),
$now->copy()->subDays(7),
])
->count();
$weekChange = $viewsLastWeek > 0
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
: ($viewsThisWeek > 0 ? 100 : 0);
// 14-day daily breakdown — segmented by viewer category (male / female / other-or-guest)
$rawDaily = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("date(video_views.watched_at) as day,
sum(case when users.gender = 'male' then 1 else 0 end) as male_cnt,
sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
count(*) as cnt")
->where('video_views.video_id', $id)
->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(video_views.watched_at)")
->orderBy('day')
->get()
->keyBy('day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$key = $d->format('Y-m-d');
$row = $rawDaily->get($key);
$daily[] = [
'date' => $key,
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => $row ? (int) $row->cnt : 0,
'male' => $row ? (int) $row->male_cnt : 0,
'female' => $row ? (int) $row->female_cnt : 0,
'other' => $row ? (int) $row->other_cnt : 0,
];
}
// Country breakdown (top 10)
$rawCountries = \DB::table('video_views')
->selectRaw("country, country_name, count(*) as cnt")
->where('video_id', $id)
->whereNotNull('country')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->limit(10)
->get();
$totalGeo = $rawCountries->sum('cnt');
$countries = $rawCountries->map(fn ($c) => [
'code' => $c->country,
'name' => $c->country_name,
'count' => (int) $c->cnt,
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
])->values();
// Peak hour (0-23) across all views
$driver = \DB::getDriverName();
$hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)';
$peakRow = \DB::table('video_views')
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
->where('video_id', $id)
->groupByRaw("{$hourExpr}")
->orderByDesc('cnt')
->first();
$peakHour = $peakRow ? (int) $peakRow->hr : null;
// Top registered viewers (by view count) — also surface device/browser of their latest visit
$topViewersRaw = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get();
// One extra query: most-recent user_agent per top viewer (small, indexed lookup)
$topViewerIds = $topViewersRaw->pluck('id')->all();
$latestUaByUid = [];
if ($topViewerIds) {
$latestRows = \DB::table('video_views as v1')
->whereIn('v1.user_id', $topViewerIds)
->where('v1.video_id', $id)
->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
->get(['v1.user_id', 'v1.user_agent']);
foreach ($latestRows as $row) {
$latestUaByUid[$row->user_id] = $row->user_agent;
}
}
$topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
$ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$guestViews = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent activity — grouped by viewer.
// Registered users group by user_id. For guests we layer three signals,
// strongest first: device_hash (multi-signal browser fingerprint) → device_id
// cookie → ip_address (legacy / cookie-less). This survives cookie clears,
// incognito mode, browser swaps, and VPN / country hops.
$recentRaw = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select(
'video_views.watched_at',
'video_views.country',
'video_views.ip_address',
'video_views.device_id',
'video_views.device_hash',
'video_views.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar',
'users.username as user_channel'
)
->where('video_views.video_id', $id)
->orderByDesc('video_views.watched_at')
->limit(500)
->get();
$groups = [];
foreach ($recentRaw as $r) {
if ($r->user_id) {
$key = 'u:' . $r->user_id;
} else {
$guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
$key = 'g:' . $guestKey;
}
if (! isset($groups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$groups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->watched_at,
];
}
$groups[$key]['count']++;
// first row is the most recent (orderByDesc) so device/browser reflect their latest visit
}
$recentViewers = array_values($groups);
// Download details
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
// Per-type breakdown
$dlByType = \DB::table('video_downloads')
->selectRaw('type, count(*) as cnt')
->where('video_id', $id)
->groupBy('type')
->pluck('cnt', 'type');
// Per-user download counts (top 20 logged-in users) + device/browser of their latest download
$dlUsersRaw = \DB::table('video_downloads')
->join('users', 'users.id', '=', 'video_downloads.user_id')
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
->where('video_downloads.video_id', $id)
->whereNotNull('video_downloads.user_id')
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('cnt')
->limit(20)
->get();
$dlUserIds = $dlUsersRaw->pluck('id')->all();
$dlLatestUaByUid = [];
if ($dlUserIds) {
$latestDl = \DB::table('video_downloads as d1')
->whereIn('d1.user_id', $dlUserIds)
->where('d1.video_id', $id)
->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
->get(['d1.user_id', 'd1.user_agent']);
foreach ($latestDl as $row) {
$dlLatestUaByUid[$row->user_id] = $row->user_agent;
}
}
$dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
$ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$dlGuests = \DB::table('video_downloads')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent download activity — grouped by downloader.
// Guests are keyed by ip_address (video_downloads doesn't track device_id);
// device/browser comes from the most-recent download's user_agent.
$dlRecentRaw = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select(
'video_downloads.type',
'video_downloads.downloaded_at',
'video_downloads.country',
'video_downloads.country_name',
'video_downloads.ip_address',
'video_downloads.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar'
)
->where('video_downloads.video_id', $id)
->orderByDesc('video_downloads.downloaded_at')
->limit(500)
->get();
$dlGroups = [];
foreach ($dlRecentRaw as $r) {
$key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
if (! isset($dlGroups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$dlGroups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'type' => $r->type,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->downloaded_at,
];
}
$dlGroups[$key]['count']++;
}
$dlRecent = array_values($dlGroups);
// Gender breakdown (authenticated viewers only)
$genderRows = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.gender, count(*) as cnt')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.gender')
->groupBy('users.gender')
->get();
$totalGender = $genderRows->sum('cnt');
$genders = $genderRows->map(fn ($g) => [
'gender' => $g->gender,
'count' => (int) $g->cnt,
'pct' => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0,
])->values();
// Age group breakdown (authenticated viewers with birthday set)
$ageExpr = $driver === 'sqlite'
? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)"
: "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)";
$rawAges = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("{$ageExpr} as age, users.gender as gender")
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday')
->get();
$blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
$ageBuckets = [
'Under 13' => $blank, '1317' => $blank, '1824' => $blank, '2534' => $blank,
'3544' => $blank, '4554' => $blank, '5564' => $blank, '65+' => $blank,
];
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]['count']++;
if ($row->gender === 'male') $ageBuckets[$bucket]['male']++;
elseif ($row->gender === 'female') $ageBuckets[$bucket]['female']++;
else $ageBuckets[$bucket]['other']++;
}
$totalAge = array_sum(array_column($ageBuckets, 'count'));
$ageGroups = collect($ageBuckets)
->filter(fn ($b) => $b['count'] > 0)
->map(fn ($b, $label) => [
'label' => $label,
'count' => $b['count'],
'male' => $b['male'],
'female' => $b['female'],
'other' => $b['other'],
'pct' => $totalAge > 0 ? round($b['count'] / $totalAge * 100) : 0,
])->values();
// Who liked this video
$likers = \DB::table('video_likes')
->join('users', 'users.id', '=', 'video_likes.user_id')
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
->where('video_likes.video_id', $id)
->orderByDesc('video_likes.created_at')
->limit(50)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'liked_at' => $u->liked_at,
]);
// ── Share analytics ────────────────────────────────────────
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
$shareIds = $shareLinks->pluck('id');
$shareReach = $shareIds->isEmpty() ? 0
: \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count();
$shareLinksCount = $shareLinks->count();
// Per-link breakdown for insights panel
$shareBreakdown = $shareLinks->map(function ($s) {
$accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();
$user = $s->user_id
? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username'])
: null;
// Most common country among accesses for this link
$topCountry = \DB::table('share_accesses')
->where('share_id', $s->id)
->whereNotNull('country')
->selectRaw('country, country_name, count(*) as cnt')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->first();
return [
'token' => $s->token,
'sharer' => $user ? $user->name : 'Guest',
'sharer_id' => $user ? $user->id : null,
'sharer_channel' => $user ? ($user->username ?? $user->id) : null,
'avatar' => $user
? ($user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id)
: null,
'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null,
'reach' => $accesses,
'created_at' => $s->created_at,
];
})->sortByDesc('reach')->values()->take(10);
// ── Skip rate ──────────────────────────────────────────────
// Skipped = watched_seconds < max(10, 10% of duration)
$duration = (int) ($video->duration ?? 0);
$skipThreshold = max(10, (int) floor($duration * 0.10));
$skippedViews = \DB::table('video_views')
->where('video_id', $id)
->where('watched_seconds', '<', $skipThreshold)
->count();
$skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;
// ── Save rate ──────────────────────────────────────────────
// Distinct users who added this video to a playlist (excluding the uploader's own playlists)
$saveCount = \DB::table('playlist_videos')
->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
->where('playlist_videos.video_id', $id)
->where('playlists.user_id', '!=', $video->user_id)
->distinct('playlists.user_id')
->count('playlists.user_id');
$uniqueViewersAll = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;
// ── Profile (wall) visits originating from this video ───────
$profileVisits = \DB::table('profile_visits')
->where('profile_user_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── New subscribers driven by this video ───────────────────
$newSubscribers = \DB::table('user_subscriptions')
->where('channel_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── Comments (including replies) ───────────────────────────
$commentsCount = \DB::table('comments')->where('video_id', $id)->count();
// ── Accounts reached (distinct users + distinct guest devices) ──
$reachedUsers = $uniqueViewersAll;
$reachedGuests = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->whereNotNull('device_id')
->distinct('device_id')
->count('device_id');
$accountsReached = $reachedUsers + $reachedGuests;
return response()->json([
'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers,
'skip_rate' => $skipRate,
'skipped_views' => $skippedViews,
'skip_threshold' => $skipThreshold,
'save_count' => $saveCount,
'save_rate' => $saveRate,
'profile_visits' => $profileVisits,
'new_subscribers' => $newSubscribers,
'comments_count' => $commentsCount,
'accounts_reached' => $accountsReached,
'reached_users' => $reachedUsers,
'reached_guests' => $reachedGuests,
'top_viewers' => $topViewers,
'guest_views' => $guestViews,
'recent_viewers' => $recentViewers,
'views_today' => $viewsToday,
'views_this_week' => $viewsThisWeek,
'views_last_week' => $viewsLastWeek,
'week_change' => $weekChange,
'downloads' => $totalDownloads,
'dl_video' => (int) ($dlByType->get('video') ?? 0),
'dl_mp3' => (int) ($dlByType->get('mp3') ?? 0),
'dl_users' => $dlUsers,
'dl_guests' => $dlGuests,
'dl_recent' => $dlRecent,
'shares' => $shareReach,
'share_links' => $shareLinksCount,
'share_breakdown' => $shareBreakdown,
'countries' => $countries,
'daily' => $daily,
'peak_hour' => $peakHour,
'likes' => $video->like_count,
'likers' => $likers,
'genders' => $genders,
'age_groups' => $ageGroups,
]);
}
// ── Drill-down: viewers from one country ──────────────────────────────
public function insightsCountry(Video $video, string $country)
{
if (Auth::id() !== $video->user_id) abort(403);
$id = $video->id;
$country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2));
$totalViews = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count();
$countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name');
$guestCount = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count();
$registeredUsers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->where('video_views.country', $country)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : '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,
]);
}
// ── Backfill a device fingerprint after the JS computes it ────────────
// On the very first visit the cookie isn't there yet, so the view row is
// inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
// ms later with the freshly-computed hash so we can stamp it onto the row
// that was just inserted (and the future cookie does the rest).
public function identify(Request $request, Video $video)
{
$hash = (string) $request->input('hash', '');
if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
return response()->json(['ok' => false], 422);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
// Update the latest matching view row for this caller (user OR cookie OR IP)
// within a short window so we don't accidentally stamp someone else's row.
$q = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(10))
->whereNull('device_hash');
if (Auth::check()) {
$q->where('user_id', Auth::id());
} else {
$did = $request->cookie('_did');
$q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
if ($did) $w->where('device_id', $did);
$w->orWhere('ip_address', $ip);
});
}
$q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);
return response()->json(['ok' => true])
->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
}
// ── User-agent → coarse device / browser / OS labels ──────────────────
// Shared by every insights endpoint so labels stay consistent.
private function parseUserAgent(?string $ua): array
{
if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];
// Device family
$device = 'Desktop';
if (preg_match('/iPad/i', $ua)) $device = 'iPad';
elseif (preg_match('/Tablet/i', $ua)) $device = 'Tablet';
elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
elseif (preg_match('/Android/i', $ua)) $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
elseif (preg_match('/Mobile/i', $ua)) $device = 'Mobile';
// OS
$os = 'Unknown';
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows 10/11';
elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m)) $os = 'macOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m)) $os = 'Android ' . $m[1];
elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m)) $os = 'iOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m)) $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
// Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
$browser = 'Unknown';
if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m)) $browser = 'Edge ' . $m[1];
elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m)) $browser = 'Opera ' . $m[1];
elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m)) $browser = 'Firefox ' . $m[1];
elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m)) $browser = 'Chrome ' . $m[1];
elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m)) $browser = 'Safari ' . $m[1];
elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m)) $browser = 'Safari ' . $m[1];
return ['device' => $device, 'os' => $os, 'browser' => $browser];
}
// ── Drill-down: detail for one share link ─────────────────────────────
public function insightsShare(Video $video, string $token)
{
if (Auth::id() !== $video->user_id) abort(403);
$share = \DB::table('video_shares')
->where('video_id', $video->id)
->where('token', $token)
->first();
if (! $share) abort(404);
// Sharer profile
$sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
if ($share->user_id) {
$u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
if ($u) {
$sharer = [
'is_guest' => false,
'id' => $u->id,
'name' => $u->name,
'channel' => $u->username,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
];
}
}
$accesses = \DB::table('share_accesses')
->where('share_id', $share->id)
->orderByDesc('accessed_at')
->get();
// Country / device / browser / os aggregation
$countries = [];
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($accesses as $a) {
$code = $a->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
$p = $this->parseUserAgent($a->user_agent ?? null);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
$recent = $accesses->take(50)->map(function ($a) {
$p = $this->parseUserAgent($a->user_agent ?? null);
return [
'at' => $a->accessed_at,
'country' => $a->country,
'device' => $p['device'],
'browser' => $p['browser'],
];
})->values();
return response()->json([
'token' => $share->token,
'created_at' => $share->created_at,
'sharer' => $sharer,
'reach' => $accesses->count(),
'countries' => array_values($countries),
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
// ── Drill-down: one viewer's full view history on this video ─────────
// $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
public function insightsViewer(Video $video, string $who)
{
if (Auth::id() !== $video->user_id) abort(403);
$who = substr($who, 0, 80);
$isUser = str_starts_with($who, 'u:');
$key = substr($who, 2);
$q = \DB::table('video_views')->where('video_id', $video->id);
if ($isUser) {
$q->where('user_id', (int) $key);
} else {
// Match across all three guest signals: fingerprint hash > device cookie > legacy IP
$q->whereNull('user_id')->where(function ($w) use ($key) {
$w->where('device_hash', $key)
->orWhere('device_id', $key)
->orWhere('ip_address', $key);
});
}
$rows = $q->orderByDesc('watched_at')
->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);
if ($rows->isEmpty()) abort(404);
// Build identity block
$identity = [
'is_guest' => ! $isUser,
'name' => 'Guest',
'avatar' => null,
'channel' => null,
];
if ($isUser) {
$user = \App\Models\User::find((int) $key);
if ($user) {
$identity = [
'is_guest' => false,
'id' => $user->id,
'name' => $user->name,
'channel' => $user->username,
'avatar' => $user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id,
];
}
} else {
$identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
}
// Country aggregation
$countries = [];
foreach ($rows as $r) {
$code = $r->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
// Device / browser / OS — parse on the fly from user_agent
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($rows as $r) {
$p = $this->parseUserAgent($r->user_agent);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
// Recent timestamps (cap 50 for the modal)
$recent = $rows->take(50)->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
]);
return response()->json([
'identity' => $identity,
'total' => $rows->count(),
'first_at' => $rows->last()->watched_at,
'last_at' => $rows->first()->watched_at,
'countries' => $countries,
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
}