ghassan a4384113c2 Audio songs: one-folder storage, version-aware download/share, GPU-checked renders
Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

2877 lines
123 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,
'redirect' => route('videos.show', $video),
]);
} catch (\Throwable $e) {
\Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
}
public function showByToken(Request $request, string $token)
{
$video = Video::where('share_token', $token)->firstOrFail();
if (! $video->canView(Auth::user())) {
abort(404);
}
return $this->show($request, $video);
}
public function show(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
$message = $video->isPrivate()
? 'This video is private.'
: 'This video is no longer available.';
return redirect('/')->with('toast_error', $message);
}
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
if (Auth::check()) {
$exists = \DB::table('video_views')
->where('user_id', Auth::id())
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->exists();
if (! $exists) {
\DB::table('video_views')->insert([
'user_id' => Auth::id(),
'video_id' => $video->id,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'watched_at' => now(),
]);
}
} else {
// Guest: deduplicate by IP within the last hour
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('ip_address', $ip)
->where('watched_at', '>', now()->subHour())
->exists();
if (! $exists) {
\DB::table('video_views')->insert([
'user_id' => null,
'video_id' => $video->id,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'watched_at' => now(),
]);
}
}
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', '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',
};
// Set persistent device-ID cookie used for share-link dedup
$did = $request->cookie('_did') ?: (string) Str::uuid();
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
public function playerData(Video $video, Request $request)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$coverUrl = $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
$slides = $video->slides->count() > 1
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'flag' => $allLangData[$t->language]['flag'] ?? null,
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all();
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
'description' => $video->description ?? '',
'download_url' => route('videos.downloadMp3', $video),
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
]);
}
public function matchData(Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
return response()->json([
'success' => true,
'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
]);
}
public function edit(Video $video, Request $request)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (! $request->expectsJson() && ! $request->ajax()) {
return view('videos.edit', compact('video'));
}
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
'id' => $s->id,
'url' => $s->url,
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]),
])->values();
return response()->json([
'success' => true,
'video' => [
'id' => $video->id,
'title' => $video->title,
'description' => $video->description,
'thumbnail' => $video->thumbnail,
'thumbnail_url' => $video->thumbnail_url,
'visibility' => $video->visibility ?? 'public',
'type' => $video->type ?? 'generic',
'download_access' => $video->download_access,
'is_audio' => $this->isAudioOnlyFile($video),
'slides' => $slides,
'language' => $video->language,
'audio_tracks' => $audioTracks,
],
]);
}
public function update(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'slides_add' => 'nullable|array',
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'primary_language' => 'nullable|string|max:10',
'extra_track_files' => 'nullable|array',
'extra_track_files.*' => 'file',
'extra_track_languages' => 'nullable|array',
'extra_track_languages.*' => 'nullable|string|max:10',
'extra_track_titles' => 'nullable|array',
'extra_track_titles.*'=> 'nullable|string|max:255',
'track_title_updates' => 'nullable|array',
'track_title_updates.*' => 'nullable|string|max:255',
'track_description_updates' => 'nullable|array',
'track_description_updates.*' => 'nullable|string',
'track_language_updates' => 'nullable|array',
'track_language_updates.*' => 'nullable|string|max:10',
'track_file_updates' => 'nullable|array',
'track_file_updates.*' => 'nullable|file',
'promote_track_id' => 'nullable|integer',
'delete_track_ids' => 'nullable|array',
'delete_track_ids.*' => 'integer',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
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);
\DB::table('video_downloads')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'type' => $type,
'downloaded_at' => now(),
]);
\DB::table('videos')->where('id', $video->id)->increment('download_count');
}
public function recordShare(Video $video)
{
$userId = Auth::id();
// Authenticated users reuse their existing share token for this video
if ($userId) {
$existing = \DB::table('video_shares')
->where('video_id', $video->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('share.access', $existing->token)]);
}
}
// Generate a unique 10-char token
do {
$token = Str::random(10);
} while (\DB::table('video_shares')->where('token', $token)->exists());
\DB::table('video_shares')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('share.access', $token)]);
}
public function accessShare(Request $request, string $token)
{
$share = \DB::table('video_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$video = Video::find($share->video_id);
if (! $video || ! $video->canView(Auth::user())) {
return redirect('/');
}
// Identify device via persistent cookie; generate one for first-time visitors
$did = $request->cookie('_did') ?: (string) Str::uuid();
// Record only the first access from this device for this share link
$seen = \DB::table('share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
\DB::table('share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name'=> $geo['country_name'] ?? null,
'accessed_at' => now(),
]);
}
// 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 insights(Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$id = $video->id;
$now = now();
$totalViews = \DB::table('video_views')->where('video_id', $id)->count();
$uniqueViewers = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$viewsToday = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->startOfDay())
->count();
$viewsThisWeek = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->subDays(7))
->count();
$viewsLastWeek = \DB::table('video_views')
->where('video_id', $id)
->whereBetween('watched_at', [
$now->copy()->subDays(14),
$now->copy()->subDays(7),
])
->count();
$weekChange = $viewsLastWeek > 0
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
: ($viewsThisWeek > 0 ? 100 : 0);
// 14-day daily breakdown
$rawDaily = \DB::table('video_views')
->selectRaw("date(watched_at) as day, count(*) as cnt")
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(watched_at)")
->orderBy('day')
->pluck('cnt', 'day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$daily[] = [
'date' => $d->format('Y-m-d'),
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0),
];
}
// Country breakdown (top 10)
$rawCountries = \DB::table('video_views')
->selectRaw("country, country_name, count(*) as cnt")
->where('video_id', $id)
->whereNotNull('country')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->limit(10)
->get();
$totalGeo = $rawCountries->sum('cnt');
$countries = $rawCountries->map(fn ($c) => [
'code' => $c->country,
'name' => $c->country_name,
'count' => (int) $c->cnt,
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
])->values();
// Peak hour (0-23) across all views
$driver = \DB::getDriverName();
$hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)';
$peakRow = \DB::table('video_views')
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
->where('video_id', $id)
->groupByRaw("{$hourExpr}")
->orderByDesc('cnt')
->first();
$peakHour = $peakRow ? (int) $peakRow->hr : null;
// Top registered viewers (by view count)
$topViewers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
$guestViews = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->count();
// 10 most recent views
$recentViewers = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select(
'video_views.watched_at',
'video_views.country',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar',
'users.username as user_channel'
)
->where('video_views.video_id', $id)
->orderByDesc('video_views.watched_at')
->limit(10)
->get()
->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
// Download details
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
// Per-type breakdown
$dlByType = \DB::table('video_downloads')
->selectRaw('type, count(*) as cnt')
->where('video_id', $id)
->groupBy('type')
->pluck('cnt', 'type');
// Per-user download counts (top 20 logged-in users)
$dlUsers = \DB::table('video_downloads')
->join('users', 'users.id', '=', 'video_downloads.user_id')
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
->where('video_downloads.video_id', $id)
->whereNotNull('video_downloads.user_id')
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
$dlGuests = \DB::table('video_downloads')
->where('video_id', $id)
->whereNull('user_id')
->count();
// 10 most recent downloads
$dlRecent = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select(
'video_downloads.id',
'video_downloads.type',
'video_downloads.downloaded_at',
'video_downloads.country',
'video_downloads.country_name',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar'
)
->where('video_downloads.video_id', $id)
->orderByDesc('video_downloads.downloaded_at')
->limit(10)
->get()
->map(fn ($r) => [
'type' => $r->type,
'at' => $r->downloaded_at,
'country' => $r->country,
'country_name'=> $r->country_name,
'user_id' => $r->user_id,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
// Gender breakdown (authenticated viewers only)
$genderRows = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.gender, count(*) as cnt')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.gender')
->groupBy('users.gender')
->get();
$totalGender = $genderRows->sum('cnt');
$genders = $genderRows->map(fn ($g) => [
'gender' => $g->gender,
'count' => (int) $g->cnt,
'pct' => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0,
])->values();
// Age group breakdown (authenticated viewers with birthday set)
$ageExpr = $driver === 'sqlite'
? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)"
: "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)";
$rawAges = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("{$ageExpr} as age")
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday')
->get();
$ageBuckets = ['Under 13' => 0, '1317' => 0, '1824' => 0, '2534' => 0, '3544' => 0, '4554' => 0, '5564' => 0, '65+' => 0];
foreach ($rawAges as $row) {
$age = (int) $row->age;
$bucket = match (true) {
$age < 13 => 'Under 13',
$age <= 17 => '1317',
$age <= 24 => '1824',
$age <= 34 => '2534',
$age <= 44 => '3544',
$age <= 54 => '4554',
$age <= 64 => '5564',
default => '65+',
};
$ageBuckets[$bucket]++;
}
$totalAge = array_sum($ageBuckets);
$ageGroups = collect($ageBuckets)
->filter(fn ($cnt) => $cnt > 0)
->map(fn ($cnt, $label) => [
'label' => $label,
'count' => $cnt,
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
])->values();
// Who liked this video
$likers = \DB::table('video_likes')
->join('users', 'users.id', '=', 'video_likes.user_id')
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
->where('video_likes.video_id', $id)
->orderByDesc('video_likes.created_at')
->limit(50)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'liked_at' => $u->liked_at,
]);
// ── Share analytics ────────────────────────────────────────
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
$shareIds = $shareLinks->pluck('id');
$shareReach = $shareIds->isEmpty() ? 0
: \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count();
$shareLinksCount = $shareLinks->count();
// Per-link breakdown for insights panel
$shareBreakdown = $shareLinks->map(function ($s) {
$accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();
$user = $s->user_id
? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username'])
: null;
// Most common country among accesses for this link
$topCountry = \DB::table('share_accesses')
->where('share_id', $s->id)
->whereNotNull('country')
->selectRaw('country, country_name, count(*) as cnt')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->first();
return [
'token' => $s->token,
'sharer' => $user ? $user->name : 'Guest',
'sharer_id' => $user ? $user->id : null,
'sharer_channel' => $user ? ($user->username ?? $user->id) : null,
'avatar' => $user
? ($user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id)
: null,
'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null,
'reach' => $accesses,
'created_at' => $s->created_at,
];
})->sortByDesc('reach')->values()->take(10);
return response()->json([
'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers,
'top_viewers' => $topViewers,
'guest_views' => $guestViews,
'recent_viewers' => $recentViewers,
'views_today' => $viewsToday,
'views_this_week' => $viewsThisWeek,
'views_last_week' => $viewsLastWeek,
'week_change' => $weekChange,
'downloads' => $totalDownloads,
'dl_video' => (int) ($dlByType->get('video') ?? 0),
'dl_mp3' => (int) ($dlByType->get('mp3') ?? 0),
'dl_users' => $dlUsers,
'dl_guests' => $dlGuests,
'dl_recent' => $dlRecent,
'shares' => $shareReach,
'share_links' => $shareLinksCount,
'share_breakdown' => $shareBreakdown,
'countries' => $countries,
'daily' => $daily,
'peak_hour' => $peakHour,
'likes' => $video->like_count,
'likers' => $likers,
'genders' => $genders,
'age_groups' => $ageGroups,
]);
}
// ── Drill-down: viewers from one country ──────────────────────────────
public function insightsCountry(Video $video, string $country)
{
if (Auth::id() !== $video->user_id) abort(403);
$id = $video->id;
$country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2));
$totalViews = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count();
$countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name');
$guestCount = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count();
$registeredUsers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->where('video_views.country', $country)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
// 14-day trend for this country
$now = now();
$rawDaily = \DB::table('video_views')
->selectRaw("date(watched_at) as day, count(*) as cnt")
->where('video_id', $id)
->where('country', $country)
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(watched_at)")
->orderBy('day')
->pluck('cnt', 'day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$daily[] = ['label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0)];
}
return response()->json([
'country' => $country,
'country_name' => $countryName ?? $country,
'total_views' => $totalViews,
'registered_users' => $registeredUsers,
'guest_count' => $guestCount,
'daily' => $daily,
]);
}
// ── Drill-down: viewers on a specific day ─────────────────────────────
public function insightsDay(Video $video, string $date)
{
if (Auth::id() !== $video->user_id) abort(403);
try {
$day = \Carbon\Carbon::createFromFormat('Y-m-d', $date, config('app.timezone'));
} catch (\Throwable) {
abort(400, 'Invalid date');
}
$id = $video->id;
$start = $day->copy()->startOfDay();
$end = $day->copy()->endOfDay();
$totalViews = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->count();
$guestCount = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->whereNull('user_id')->count();
$registeredUsers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->whereBetween('video_views.watched_at', [$start, $end])
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
// Hourly breakdown (0-23)
$driver = \DB::getDriverName();
$hourExpr = $driver === 'sqlite' ? "CAST(strftime('%H', watched_at) AS INTEGER)" : 'HOUR(watched_at)';
$rawHourly = \DB::table('video_views')
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
->where('video_id', $id)
->whereBetween('watched_at', [$start, $end])
->groupByRaw($hourExpr)
->orderBy('hr')
->pluck('cnt', 'hr');
$hourly = [];
for ($h = 0; $h < 24; $h++) {
$hourly[] = [
'hour' => $h,
'label' => ($h === 0 ? '12am' : ($h < 12 ? $h . 'am' : ($h === 12 ? '12pm' : ($h - 12) . 'pm'))),
'count' => (int) ($rawHourly->get($h) ?? 0),
];
}
// Top countries that day
$countries = \DB::table('video_views')
->selectRaw("country, country_name, count(*) as cnt")
->where('video_id', $id)
->whereBetween('watched_at', [$start, $end])
->whereNotNull('country')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->limit(5)
->get()
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
return response()->json([
'date' => $day->format('M d, Y'),
'day_of_week' => $day->format('l'),
'total_views' => $totalViews,
'registered_users' => $registeredUsers,
'guest_count' => $guestCount,
'hourly' => $hourly,
'countries' => $countries,
]);
}
// ── Drill-down: one user's full download history on this video ────────
public function insightsDownloaderHistory(Video $video, int $userId)
{
if (Auth::id() !== $video->user_id) abort(403);
$user = \App\Models\User::findOrFail($userId);
$records = \DB::table('video_downloads')
->where('video_id', $video->id)
->where('user_id', $userId)
->orderByDesc('downloaded_at')
->get(['id', 'type', 'country', 'country_name', 'downloaded_at'])
->map(fn ($r) => [
'type' => $r->type,
'country' => $r->country,
'country_name' => $r->country_name,
'at' => $r->downloaded_at,
]);
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
],
'total' => $records->count(),
'records' => $records,
]);
}
}