ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

3804 lines
165 KiB
PHP
Raw Permalink 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', 'lyricsProgress']);
}
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',
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
// If absent for a given index, the track inherits the primary's slides
// at render time via Video::slidesForTrack().
'extra_track_slides' => 'nullable|array',
'extra_track_slides.*' => 'nullable|array',
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$videoFile = $request->file('video');
$filename = self::generateFilename($videoFile->getClientOriginalExtension());
$path = $videoFile->storeAs('public/videos', $filename);
// Get file info
$fileSize = $videoFile->getSize();
$mimeType = $videoFile->getMimeType();
$thumbnailPath = null;
$slideFiles = [];
if ($isAudioUpload && $request->hasFile('slides')) {
// Audio upload: save all slides; first slide becomes the thumbnail
foreach ($request->file('slides') as $slideFile) {
$fname = self::generateFilename($slideFile->getClientOriginalExtension());
$slideFile->storeAs('public/thumbnails', $fname);
$slideFiles[] = $fname;
}
$thumbnailPath = 'public/thumbnails/' . $slideFiles[0];
} elseif ($request->hasFile('thumbnail')) {
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
} elseif (! $isAudioUpload) {
try {
$ffmpeg = FFMpeg::create();
$videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) {
$video = $ffmpeg->open($videoPath);
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
$thumbFilename = Str::uuid().'.jpg';
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
if (! file_exists(storage_path('app/public/thumbnails'))) {
mkdir(storage_path('app/public/thumbnails'), 0755, true);
}
$frame->save($thumbFullPath);
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
}
} catch (\Exception $e) {
\Log::error('FFmpeg thumbnail error: '.$e->getMessage());
}
}
$width = null;
$height = null;
$orientation = 'landscape';
if (! $isAudioUpload) {
try {
$ffprobe = FFProbe::create();
$videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) {
$streams = $ffprobe->streams($videoPath);
$videoStream = $streams->videos()->first();
if ($videoStream) {
$width = $videoStream->get('width');
$height = $videoStream->get('height');
if ($width && $height) {
if ($height > $width) $orientation = 'portrait';
elseif ($width > $height) $orientation = 'landscape';
else $orientation = 'square';
}
}
}
} catch (\Exception $e) {
\Log::error('FFprobe error: '.$e->getMessage());
}
}
$video = Video::create([
'user_id' => Auth::id(),
'title' => $request->title,
'description' => \App\Support\HtmlSanitizer::clean($request->description),
'filename' => $filename,
'path' => $path,
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
'size' => $fileSize,
'mime_type' => $mimeType,
'orientation' => $orientation,
'width' => $width,
'height' => $height,
'status' => $isAudioUpload ? 'ready' : 'processing',
'visibility' => $request->visibility ?? 'public',
'type' => $isAudioUpload ? 'music' : ($request->type ?? 'generic'),
'download_access' => $request->input('download_access', 'disabled'),
'share_token' => Str::random(32),
'language' => $request->input('primary_language') ?: null,
]);
// Save individual slide records for audio uploads with multiple images
foreach ($slideFiles as $position => $fname) {
VideoSlide::create([
'video_id' => $video->id,
'filename' => $fname,
'position' => $position,
]);
}
$nas = app(\App\Services\NasSyncService::class);
$nasUploadSucceeded = false;
if ($nas->isEnabled()) {
// ── NAS-primary: push directly to NAS, delete local temp files ──
try {
$video->load('slides');
// Build slide abs-paths map for audio uploads
$slideAbsPaths = [];
foreach ($slideFiles as $pos => $fname) {
$slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
}
$tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
// For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
if ($isAudioUpload) $tempThumbAbs = null;
$nas->uploadDirectToNas(
$video,
storage_path('app/' . $path),
$tempThumbAbs,
$slideAbsPaths
);
$video->refresh();
$nasUploadSucceeded = true;
} catch (\Throwable $e) {
\Log::error('uploadDirectToNas failed (falling back to local): ' . $e->getMessage());
// NAS went down mid-upload — organise the surviving local files below
}
}
if ($nasUploadSucceeded) {
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
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', []);
$trackSlides = $request->file('extra_track_slides') ?: [];
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
// Create a placeholder record to get the DB ID for the filename
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
// Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
$trackDir = $nas->trackDir($video, $track);
$nas->mkdirp($trackDir);
$canonical = "audio.{$ext}";
$nasPath = "{$trackDir}/{$canonical}";
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => $canonical,
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
// Fall back to local storage
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
// ── Optional per-track slides ──────────────────────────────
// The track only owns slides that were uploaded for it. If none
// were uploaded, the player falls back to the primary's at render
// time via Video::slidesForTrack — no row needed here.
$files = $trackSlides[$i] ?? null;
if (is_array($files) && count($files) > 0) {
$this->storeTrackSlides($video, $track, $files, $nas);
}
}
}
// ── Synced lyrics generation (audio/music uploads only) ───────────────
if ($isAudioUpload) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
foreach ($video->audioTracks()->pluck('id') as $tid) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
}
}
$video->load('user');
$userEmail = Auth::user()->email;
$userName = Auth::user()->name;
$uploader = Auth::user();
$subscribers = $uploader->subscribers()->get();
$subscriberEmails = $subscribers->pluck('email')->toArray();
// In-app DB notifications (fast — just inserts, no network)
if ($video->visibility === 'public') {
foreach ($subscribers as $subscriber) {
try {
$subscriber->notify(new NewVideoUploadedNotification($video, $uploader));
} catch (\Throwable $e) {
\Log::error('In-app notification error: '.$e->getMessage());
}
}
}
app()->terminating(function () use ($video, $userEmail, $userName, $uploader, $subscriberEmails) {
// Confirm upload to the uploader
try {
Mail::to($userEmail)->send(new VideoUploaded($video, $userName));
} catch (\Throwable $e) {
\Log::error('Email error: '.$e->getMessage());
}
// Email subscribers (only for public videos)
if ($video->visibility === 'public' && count($subscriberEmails) > 0) {
foreach ($subscriberEmails as $email) {
try {
Mail::to($email)->send(new NewVideoNotification($video, $uploader));
} catch (\Throwable $e) {
\Log::error('Subscriber notification error: '.$e->getMessage());
}
}
}
});
AuditLog::record('video.uploaded', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $video->title,
'details' => ['type' => $video->type, 'visibility' => $video->visibility],
]);
return response()->json([
'success' => true,
'video_id' => $video->id,
'redirect' => route('videos.show', $video),
]);
} catch (\Throwable $e) {
\Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
}
public function showByToken(Request $request, string $token)
{
$video = Video::where('share_token', $token)->firstOrFail();
if (! $video->canView(Auth::user())) {
abort(404);
}
return $this->show($request, $video);
}
public function show(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
$message = $video->isPrivate()
? 'This video is private.'
: 'This video is no longer available.';
return redirect('/')->with('toast_error', $message);
}
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
// Persistent client-side device ID (set in the response cookie below).
// Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
$viewDid = $request->cookie('_did') ?: (string) Str::uuid();
// Device fingerprint hash (set client-side by /fp.js after first paint).
// Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
// Null on the very first visit; the JS will call /identify to backfill it.
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
if (Auth::check()) {
$exists = \DB::table('video_views')
->where('user_id', Auth::id())
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->exists();
if (! $exists) {
\DB::table('video_views')->insert([
'user_id' => Auth::id(),
'video_id' => $video->id,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
} else {
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
})
->exists();
if (! $exists) {
\DB::table('video_views')->insert([
'user_id' => null,
'video_id' => $video->id,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
}
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);
// Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
// tags and <title> reflect that language track (so a shared English version shows
// the English title, not the primary). Falls back to the primary when unset.
$shareTitle = $video->title;
$shareDescription = $video->description;
if ($shareTrackId = (int) $request->input('track', 0)) {
$shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
if ($shareTrack) {
if (! empty($shareTrack->title)) $shareTitle = $shareTrack->title;
if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
}
}
$playlist = null;
$nextVideo = null;
$previousVideo = null;
$playlistVideos = null;
$playlistParam = $request->query('playlist');
if ($playlistParam) {
$playlist = Playlist::where('share_token', $playlistParam)->first();
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
// Load the videos ONCE with their owners eager-loaded, then
// compute prev/next in PHP. The old code fired 4+ separate
// queries for prev/next/list — the sidebar lag the user
// reported was almost entirely those extra round-trips.
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
// Count the playlist view (deduped per device, 1-hour window)
// after the response is flushed so we don't pay the round-trip
// on the hot path.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
}
}
$recommendedVideos = Video::public()
->where('id', '!=', $video->id)
->latest()
->limit(20)
->get();
$view = match ($video->type) {
'match' => 'videos.types.match',
'music' => 'videos.types.music',
default => 'videos.types.generic',
};
// Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $viewDid, 60 * 24 * 365 * 5));
}
public function playerData(Video $video, Request $request)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$coverUrl = $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
// Per-track slide map (key "0" = primary). Each entry already has the
// sharing fallback applied by Video::slidesForTrack — a track without its
// own slides borrows the primary's (or a sibling's) automatically.
$slideMap = ['0' => $video->slidesForTrack(null)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
foreach ($video->audioTracks as $_t) {
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
}
$slides = $slideMap['0'];
$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();
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
// Local mirror only — must not block this hot path on NAS I/O.
$nasLyrics = app(\App\Services\NasSyncService::class);
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
foreach ($video->audioTracks as $t) {
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
}
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
'slide_map' => $slideMap,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
'description' => $video->description ?? '',
'download_url' => route('videos.downloadMp3', $video),
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
'lyrics' => $lyricsMap,
]);
}
/**
* Owner-triggered lyrics generation for the current audio track (?track={id},
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
* polls player-data and shows the lyrics once the file lands.
*/
public function generateLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
}
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
}
$trackId = (int) $request->input('track', 0);
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
return response()->json(['status' => 'queued']);
}
/**
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
// Clear any in-flight progress / temp artifacts so a queued job that
// fires later can't repopulate stale output.
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
return response()->json(['status' => 'deleted']);
}
/**
* Live progress for an in-flight lyrics generation, driving the player's
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
*/
public function lyricsProgress(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['status' => 'none']);
}
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
if (is_array($data)) {
$st = $data['status'] ?? 'ready';
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
if ($st === 'failed') return response()->json(['status' => 'failed']);
}
// Live percentage from the pipeline's progress file.
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
if (is_file($progFile)) {
$p = json_decode((string) file_get_contents($progFile), true);
if (is_array($p)) {
return response()->json([
'status' => 'processing',
'pct' => (int) ($p['pct'] ?? 1),
'stage' => $p['stage'] ?? 'Working',
]);
}
}
// A 'processing' lyrics marker but no progress file yet → just queued.
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
return response()->json(['status' => 'none']);
}
/**
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
* track; preserves precise word timing for lines that weren't changed and
* redistributes timing evenly across the new words for edited lines.
*/
public function saveLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$existing = $nas->getLocalLyrics($video, $track) ?: [];
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
$inLines = $request->input('lines', []);
if (! is_array($inLines)) $inLines = [];
$out = [];
foreach ($inLines as $ln) {
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
if ($text === '' || mb_strlen($text) > 1000) {
if ($text === '') continue;
$text = mb_substr($text, 0, 1000);
}
$start = round((float) ($ln['start'] ?? 0), 3);
$end = round((float) ($ln['end'] ?? $start), 3);
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
$isSpaceless = in_array($lang, $spaceless, true);
// Keep original word timings if the text wasn't changed; otherwise
// redistribute the line's span evenly across the new tokens.
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
$sep = $isSpaceless ? '' : ' ';
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
$unchanged = $origWords
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
if ($unchanged) {
$words = array_map(fn ($w) => [
'start' => round((float) ($w['start'] ?? $start), 3),
'end' => round((float) ($w['end'] ?? $end), 3),
'text' => (string) ($w['text'] ?? ''),
], $origWords);
} else {
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
}
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
if (count($out) >= 1000) break;
}
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'ready',
'source' => 'edited',
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
'multilingual' => $existing['multilingual'] ?? false,
'lines' => $out,
'generated_at' => now()->toIso8601String(),
]);
return response()->json(['status' => 'ok', 'lines' => count($out)]);
}
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
{
$text = trim($text);
if ($text === '' || $end <= $start) {
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
}
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
$n = count($tokens);
if ($n === 0) return [];
$slice = ($end - $start) / $n;
$words = [];
foreach ($tokens as $i => $tok) {
$words[] = [
'start' => round($start + $i * $slice, 3),
'end' => round($start + ($i + 1) * $slice, 3),
'text' => $tok,
];
}
return $words;
}
public function matchData(Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
return response()->json([
'success' => true,
'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
]);
}
public function edit(Video $video, Request $request)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (! $request->expectsJson() && ! $request->ajax()) {
return view('videos.edit', compact('video'));
}
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
'id' => $s->id,
'url' => $s->url,
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]),
])->values();
return response()->json([
'success' => true,
'video' => [
'id' => $video->id,
'title' => $video->title,
'description' => $video->description,
'thumbnail' => $video->thumbnail,
'thumbnail_url' => $video->thumbnail_url,
'visibility' => $video->visibility ?? 'public',
'type' => $video->type ?? 'generic',
'download_access' => $video->download_access,
'is_audio' => $this->isAudioOnlyFile($video),
'slides' => $slides,
'language' => $video->language,
'audio_tracks' => $audioTracks,
],
]);
}
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 {
// Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
// (storeTrackLocally is only reached for music since extra tracks
// only exist on music videos.)
$songLocalDir = $nas->localVideoDir($video);
$trackFolder = $nas->trackFolderName($video, $track);
$trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
@mkdir($trackDirAbs, 0755, true);
$canonical = "audio.{$ext}";
$trackFile->move($trackDirAbs, $canonical);
// Build the NAS-relative path from the song's relative path so it
// works on both local-only and NAS-enabled setups.
$songRel = $this->relFromStoragePath($songLocalDir);
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
$track->update(['path' => $relPath, 'filename' => $canonical]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
/** Convert an absolute storage_path() value back to a storage-relative path. */
private function relFromStoragePath(string $abs): string
{
$prefix = storage_path('app/');
if (str_starts_with($abs, $prefix)) {
return substr($abs, strlen($prefix));
}
return $abs;
}
/**
* Persist per-track slides — the new track owns them via audio_track_id.
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
* slides/track-{trackId}-{position}.{ext}
* Primary slides keep the legacy slides/{position}.{ext} scheme.
*/
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
{
// Per-track slides live in the track's own folder:
// tracks/{lang-id}/slides/{position}.{ext}
// Filenames are canonical (just {position}.{ext}) because the folder
// already disambiguates by track.
$nasEnabled = $nas->isEnabled();
$songLocal = $nas->localVideoDir($video);
$songRel = $this->relFromStoragePath($songLocal);
$trackFold = $nas->trackFolderName($video, $track);
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
if ($nasEnabled) {
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
} else {
@mkdir("{$localTrackDir}/slides", 0755, true);
}
foreach ($files as $pos => $file) {
if (! $file || ! $file->isValid()) continue;
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$name = "{$pos}.{$ext}";
$slide = VideoSlide::create([
'video_id' => $video->id,
'audio_track_id' => $track->id,
'filename' => '__pending__',
'position' => $pos,
]);
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasEnabled) {
$nasPath = "{$nasTrackDir}/slides/{$name}";
if ($nas->putFile($tempAbs, $nasPath)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasPath]);
continue;
}
// Fall through to local on NAS failure
}
@mkdir("{$localTrackDir}/slides", 0755, true);
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
}
}
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
* overwrite each other even when they share a language.
*/
private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
public function hls(Video $video, $file = 'playlist.m3u8')
{
if (! $video->canView(Auth::user())) {
abort(404);
}
if (! $video->has_hls) {
abort(404, 'HLS unavailable');
}
$hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);
if (! file_exists($hlsPath)) {
abort(404);
}
$mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
? 'application/vnd.apple.mpegurl'
: 'video/mp2t';
header('Content-Type: '.$mimeType);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Range');
if (request()->header('Range')) {
$size = filesize($hlsPath);
preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches);
$start = intval($matches[1] ?? 0);
$end = $matches[2] ? intval($matches[2]) : $size - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Length: '.$length);
header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);
$handle = fopen($hlsPath, 'rb');
fseek($handle, $start);
echo fread($handle, $length);
fclose($handle);
} else {
header('Content-Length: '.filesize($hlsPath));
readfile($hlsPath);
}
exit;
}
private function checkDownloadAccess(Video $video): void
{
$access = $video->download_access ?? 'disabled';
$user = Auth::user();
match ($access) {
'disabled' => abort(403, 'Downloads are not enabled for this video.'),
'registered' => $user ? null : abort(403, 'You must be logged in to download this video.'),
'subscribers' => ($user && ($user->id === $video->user_id || $user->isSubscribedTo($video->user)))
? null
: abort(403, 'You must be subscribed to this channel to download this video.'),
default => null, // 'everyone' — allow
};
}
public function download(Video $video)
{
$this->checkDownloadAccess($video);
$this->recordDownload($video, 'video');
$path = $video->localVideoPath();
// If not on local disk, try to pull from NAS
if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
// Already a video — serve directly, no conversion needed
if (! $this->isAudioOnlyFile($video)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
return response()->download($path, $this->safeFilename($video->title, 'video') . '.' . $ext, [
'Content-Type' => $video->mime_type ?: 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, $ext),
]);
}
// Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
$lyrics = request()->boolean('lyrics');
// Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
if ($viz || $trackId || $lyrics) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// Not generated yet — let the player trigger background generation.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
// Primary plain — DB column must confirm it's the current version.
if ($video->slideshow_video_path) {
$slideshowCache = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// DB says cached but file is gone — clear the stale column
$video->update(['slideshow_video_path' => null]);
}
// Not cached — redirect to the video page which will auto-trigger the
// background generation progress bar (startSlideshowDownload). This avoids
// blocking the HTTP worker with a synchronous FFmpeg encode.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
/**
* Extract up to three dominant, saturated colours from a cover image — the same
* algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
* so the baked-in download bars match what viewers see on the page. Returns three
* lowercase hex strings ordered left → middle → right of the bar gradient; falls
* back to light greys when nothing usable is found.
*/
private function slideVisualizerColors(?string $imagePath): array
{
$default = ['ffffff', 'c8c8c8', 'aaaaaa'];
if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
return $default;
}
$raw = @file_get_contents($imagePath);
$img = $raw ? @imagecreatefromstring($raw) : false;
if (! $img) return $default;
$t = imagecreatetruecolor(24, 24);
imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
imagedestroy($img);
$buckets = [];
for ($y = 0; $y < 24; $y++) {
for ($x = 0; $x < 24; $x++) {
$p = imagecolorat($t, $x, $y);
$r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
$bright = ($r + $g + $b) / 3;
if ($bright < 25 || $bright > 230) continue; // skip near-black / near-white
$mx = max($r, $g, $b); $mn = min($r, $g, $b);
if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue; // skip low-saturation
$k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
$buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
}
}
imagedestroy($t);
if (! $buckets) return $default;
usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
$avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];
// Most-common colour first, then the next colours that are visually distinct from it.
$chosen = [$avg($buckets[0])];
for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
$c = $avg($buckets[$i]); $far = true;
foreach ($chosen as $e) {
if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
}
if ($far) $chosen[] = $c;
}
while (count($chosen) < 3) $chosen[] = $chosen[0];
return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
}
// ── Background slideshow generation + progress polling ────────
public function slideshowGenerate(Request $request, Video $video)
{
$this->checkDownloadAccess($video);
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Not an audio file'], 422);
}
// Version-aware: render the video from the audio the viewer is playing — the
// primary track, or a specific language track (?track={id}). The optional
// visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$lyrics = $request->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
// Audio source = the version being played.
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$audioPath = $track->localPath();
if (! file_exists($audioPath) && $nas->isEnabled()) {
$audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
}
} else {
$audioPath = $video->localVideoPath();
}
if (! file_exists($audioPath)) {
return response()->json(['error' => 'Audio file not found'], 404);
}
$outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . $suffix . '.txt';
// Already cached. Only the primary plain video is tracked by the DB column; every
// other variant (a language track and/or visualizer) is guarded by file existence.
// All are invalidated together when the slides are edited (see update()).
$usesDbColumn = (! $viz && ! $trackId);
$cached = $usesDbColumn
? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
: (file_exists($outPath) && filesize($outPath) > 0);
if ($cached) {
return response()->json(['status' => 'ready']);
}
// Stale file on disk (e.g. slides were edited) — delete it before regenerating
if (file_exists($outPath)) {
@unlink($outPath);
}
// Already running — return so the frontend keeps polling
if (file_exists($pidFile)) {
$pid = (int) trim(file_get_contents($pidFile));
if ($pid > 0 && file_exists("/proc/{$pid}")) {
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
. escapeshellarg($audioPath) . ' 2>/dev/null', $dO);
return response()->json(['status' => 'running', 'duration' => (float) trim($dO[0] ?? '0')]);
}
}
// Probe duration
exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
. escapeshellarg($audioPath) . ' 2>/dev/null', $durOut);
$dur = isset($durOut[0]) ? (float) trim($durOut[0]) : null;
if (! $dur || $dur < 1) {
return response()->json(['error' => 'Cannot probe audio duration'], 500);
}
// Use the slides for the track being rendered, applying the per-track
// sharing fallback (own → primary → sibling). Ensures the download .mp4
// matches the slideshow the listener saw in the player.
$video->loadMissing('slides');
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
// track and weave it into the final video output. $voutLabel/$assArg/
// $assTail let each render branch inject the burn uniformly.
$voutLabel = '[vout]';
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
if ($lyrics) {
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
$assFilter = 'ass=' . $assPath;
$assArg = ',' . $assFilter;
$assTail = ';[vout]' . $assFilter . '[vsub]';
$voutLabel = '[vsub]';
}
}
}
@unlink($progressFile);
@unlink($pidFile);
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
// A single slide is normally a still image, but with the visualizer overlay the
// bars animate — so it must not be encoded with -tune stillimage.
$isStillImage = ($validSlides->count() === 1) && ! $viz;
$vFlags = $this->ffmpegVideoFlags($isStillImage);
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
if ($viz) {
// ── Visualizer build ─────────────────────────────────────────────────
// Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
// multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
// this run below real-time; the loop filter makes it ~25x real-time instead.
// overlay shortest=1 bounds the otherwise-endless looped background to the audio.
$rate = 20;
$inputs = '';
$fcParts = [];
if ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
foreach ($validSlides as $i => $slide) {
$inputs .= ' -i ' . escapeshellarg($slide->localPath());
// fps MUST come last so each branch is constant-frame-rate for xfade.
$fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
. number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = $n;
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
$fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
} elseif ($validSlides->count() === 1) {
$inputs .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
} else {
$inputs .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]format=yuv420p[vbase]";
}
// Frequency bars matched to the in-player visualizer:
// • equal-width bars → fscale=lin (log made the left bars wide, right thin)
// • cover colours → render white bars (a shape+alpha mask), then tint
// them with a horizontal gradient of the cover's 3 dominant colours
// (left → middle → right), exactly like the page's canvas bars
// • translucent overlay → showfreqs already outputs a transparent backdrop,
// so we just dial the bar alpha down and composite over the artwork.
[$c0, $c1, $c2] = $this->slideVisualizerColors(
$validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
);
$fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
. ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
$fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
. ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
$fcParts[] = "[bars]alphaextract[vmask]";
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
$inputs = '';
$scaleFc = [];
foreach ($validSlides as $i => $slide) {
$imgPath = $slide->localPath();
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$xfadeFc = [];
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vout]' : "[v{$i}]";
$xfadeFc[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
$fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map ' . $voutLabel . ' -map ' . $n . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($validSlides->count() === 1) {
$imgPath = $validSlides->first()->localPath();
$cmd = "{$ffmpeg} -y"
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
. ' -vf ' . escapeshellarg($scale . $assArg)
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
} else {
$cmd = "{$ffmpeg} -y"
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
}
$cmd .= ' -progress ' . escapeshellarg($progressFile)
. ' ' . escapeshellarg($outPath);
// $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
// GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
// on CPU (libx264) and the download keeps working.
if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
$inner = $cmd
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
$bgCmd = 'nohup bash -c ' . escapeshellarg($inner)
. ' 2>' . escapeshellarg($logFile)
. ' & echo $! > ' . escapeshellarg($pidFile);
} else {
$bgCmd = 'nohup ' . $cmd
. ' 2>' . escapeshellarg($logFile)
. ' & echo $! > ' . escapeshellarg($pidFile);
}
exec($bgCmd);
return response()->json(['status' => 'started', 'duration' => $dur]);
}
public function slideshowProgress(Video $video)
{
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
$lyrics = request()->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
// Read progress content once
$content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
$isDone = strpos($content, 'progress=end') !== false;
// If progress says done OR the output file exists and process is gone
if (! $isDone && file_exists($outPath) && filesize($outPath) > 0) {
$pid = file_exists($pidFile) ? (int) trim(file_get_contents($pidFile)) : 0;
if (! ($pid > 0 && file_exists("/proc/{$pid}"))) {
$isDone = true;
}
}
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
return response()->json(['percent' => 100, 'status' => 'ready']);
}
if (! $content) {
// If a PID was recorded but the process is already gone, generation failed
if (file_exists($pidFile)) {
$pid = (int) trim(file_get_contents($pidFile));
if ($pid > 0 && ! file_exists("/proc/{$pid}")) {
return response()->json(['percent' => 0, 'status' => 'error']);
}
}
return response()->json(['percent' => 0, 'status' => 'waiting']);
}
// Parse the most-recent out_time_ms value from the progress file
preg_match_all('/out_time_ms=(\d+)/', $content, $m);
$outTimeMs = ! empty($m[1]) ? (int) end($m[1]) : 0;
// FFmpeg's out_time_ms field is actually in microseconds despite its name
$totalUs = (float) request()->query('duration', 0) * 1_000_000;
$percent = $totalUs > 0 ? min(99, (int) round(($outTimeMs / $totalUs) * 100)) : 2;
return response()->json(['percent' => $percent, 'status' => 'processing']);
}
public function downloadMp3(Video $video)
{
$this->checkDownloadAccess($video);
$path = $video->localVideoPath();
if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
$slug = $this->safeFilename($video->title, 'audio');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
// Already MP3 — serve directly
if (strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)) === 'mp3') {
$this->recordDownload($video, 'mp3');
return response()->download($path, $this->safeFilename($video->title, 'audio') . '.mp3', [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
]);
}
$isAudio = $this->isAudioOnlyFile($video);
$hwaccel = $this->ffmpegHwaccelFlags(! $isAudio);
$escaped = escapeshellarg($path);
$tmp = sys_get_temp_dir() . '/' . \Str::uuid() . '.mp3';
$cmd = "{$ffmpeg} -y {$hwaccel}-i {$escaped}"
. " -vn -acodec libmp3lame -q:a 2"
. ' ' . escapeshellarg($tmp) . ' 2>/dev/null';
set_time_limit(0);
exec($cmd, $out, $exitCode);
if ($exitCode !== 0 || ! file_exists($tmp) || filesize($tmp) === 0) {
@unlink($tmp);
abort(500, 'Failed to generate MP3 file.');
}
$this->recordDownload($video, 'mp3');
return response()->download($tmp, $this->safeFilename($video->title, 'audio') . '.mp3', [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
])->deleteFileAfterSend(true);
}
private function safeFilename(string $title, string $fallback): string
{
// Strip characters illegal on Windows/Linux/macOS filesystems, then trim
$name = preg_replace('/[\\\\\/:\*\?"<>\|]/', '', $title);
$name = trim($name);
return $name !== '' ? $name : $fallback;
}
private function contentDisposition(string $title, string $ext): string
{
$ascii = \Str::slug($title) ?: 'download';
$safe = $this->safeFilename($title, $ascii);
$utf8 = rawurlencode($safe . '.' . $ext);
return 'attachment; filename="' . $ascii . '.' . $ext . '"; filename*=UTF-8\'\'' . $utf8;
}
private function downloadSlideshowVideo(Video $video, $slides, string $audioPath, string $ffmpeg)
{
if ($video->slideshow_video_path) {
$cached = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($cached) && filesize($cached) > 0) {
return response()->download($cached, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
}
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
/** Full video codec flags for raw FFmpeg shell commands (GPU if enabled, else CPU). */
private function ffmpegVideoFlags(bool $stillImage = false): string
{
return Setting::ffmpegVideoFlags($stillImage);
}
/** CPU-only codec flags — used as fallback when GPU encoding fails. */
private function ffmpegVideoFlagsCpu(bool $stillImage = false): string
{
return Setting::ffmpegVideoFlagsCpu($stillImage);
}
/** hwaccel decode flags — only used when input is a real video file and GPU is on. */
private function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
return Setting::ffmpegHwaccelFlags($inputIsVideo);
}
/**
* Song-folder-relative path (under storage/app) for the generated "Download Video".
* Regenerable renders live in the song's own `cache/` subfolder — separated from the
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '')
. ($lyrics ? '-lyr' : '') . '.mp4';
}
/**
* Return the NAS video root directory (where thumb.jpg and slides/ live).
* When the primary file was promoted from a secondary track, video->path points
* into a 'tracks/' subfolder — in that case we go up one extra level.
*/
private function nasVideoDir(Video $video, \App\Services\NasSyncService $nas): string
{
if (str_starts_with($video->path, 'users/')) {
$dir = dirname($video->path);
if (basename($dir) === 'tracks') {
$dir = dirname($dir);
}
return $dir;
}
return $nas->resolveVideoDir($video);
}
private function isAudioOnlyFile(Video $video): bool
{
if ($video->mime_type) {
return str_starts_with($video->mime_type, 'audio/');
}
$audioExts = ['mp3', 'aac', 'm4a', 'ogg', 'wav', 'flac', 'opus', 'wma'];
return in_array(strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)), $audioExts);
}
private function recordDownload(Video $video, string $type): void
{
$request = request();
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$userId = Auth::id();
// Deduplicate: browsers/download managers sometimes retry the connection.
// Skip recording if the same user (or guest IP) already downloaded this
// video+type within the last 10 minutes.
$alreadyRecorded = \DB::table('video_downloads')
->where('video_id', $video->id)
->where('type', $type)
->where('downloaded_at', '>=', now()->subMinutes(10))
->when(
$userId !== null,
fn ($q) => $q->where('user_id', $userId),
fn ($q) => $q->whereNull('user_id')->where('ip_address', $ip)
)
->exists();
if ($alreadyRecorded) {
return;
}
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('video_downloads')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'type' => $type,
'downloaded_at' => now(),
]);
\DB::table('videos')->where('id', $video->id)->increment('download_count');
}
public function recordShare(Video $video)
{
$userId = Auth::id();
// Authenticated users reuse their existing share token for this video
if ($userId) {
$existing = \DB::table('video_shares')
->where('video_id', $video->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('share.access', $existing->token)]);
}
}
// Generate a unique 10-char token
do {
$token = Str::random(10);
} while (\DB::table('video_shares')->where('token', $token)->exists());
\DB::table('video_shares')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('share.access', $token)]);
}
/**
* Email a friend a properly-formatted share email, version-aware: the link and the
* email's title reflect the language track the sender chose. The URL is built
* server-side so outgoing mail can never carry an attacker-supplied link.
*/
public function shareByEmail(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'email' => 'required|email|max:255',
'message' => 'nullable|string|max:500',
'track' => 'nullable|integer',
]);
$trackId = (int) ($data['track'] ?? 0);
$shareTitle = $video->title;
$shareUrl = $video->share_url;
if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) {
if (! empty($track->title)) $shareTitle = $track->title;
$shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId;
}
try {
\Mail::to($data['email'])->send(new \App\Mail\VideoShared(
$video,
$shareUrl,
Auth::user(),
$data['message'] ?? null,
$shareTitle,
));
} catch (\Throwable $e) {
\Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]);
return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500);
}
return response()->json(['success' => true]);
}
/**
* Share a video directly with selected members. Each recipient gets an in-app
* notification (clicking it opens the video) and an email.
*/
public function shareWithMembers(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'user_ids' => 'required|array|min:1|max:30',
'user_ids.*' => 'integer|exists:users,id',
'message' => 'nullable|string|max:500',
]);
$sharer = Auth::user();
$message = $data['message'] ?? null;
$recipients = \App\Models\User::whereIn('id', $data['user_ids'])
->where('id', '!=', $sharer->id)
->get();
foreach ($recipients as $member) {
try {
$member->notify(new \App\Notifications\VideoSharedWithUser(
$video, $sharer, $message, $video->share_url
));
} catch (\Throwable $e) {
\Log::error('Share-to-member failed: ' . $e->getMessage(), [
'video_id' => $video->id, 'member_id' => $member->id,
]);
}
}
return response()->json(['success' => true, 'count' => $recipients->count()]);
}
public function accessShare(Request $request, string $token)
{
$share = \DB::table('video_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$video = Video::find($share->video_id);
if (! $video || ! $video->canView(Auth::user())) {
return redirect('/');
}
// Identify device via persistent cookie; generate one for first-time visitors
$did = $request->cookie('_did') ?: (string) Str::uuid();
// Record only the first access from this device for this share link
$seen = \DB::table('share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name'=> $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'accessed_at' => now(),
]);
}
// Carry the version selector (?track=) through the redirect so the recipient opens
// the exact language the sharer was listening to.
$dest = route('videos.showByToken', $video->share_token);
if ($request->filled('track')) {
$dest .= '?track=' . (int) $request->input('track');
}
return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
public function ogImage(Video $video)
{
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
if ($video->thumbnail) {
$path = $video->localThumbnailPath();
if ($path && file_exists($path)) {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
// Load source image via GD
$src = match($ext) {
'png' => @imagecreatefrompng($path),
'webp' => @imagecreatefromwebp($path),
'gif' => @imagecreatefromgif($path),
default => @imagecreatefromjpeg($path),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Fit inside 1200×630, preserving aspect ratio
$maxW = 1200; $maxH = 630;
$ratio = min($maxW / $ow, $maxH / $oh, 1.0);
$nw = (int)round($ow * $ratio); $nh = (int)round($oh * $ratio);
$dst = imagecreatetruecolor($nw, $nh);
// Preserve transparency before converting to JPEG (fill white)
$white = imagecolorallocate($dst, 255, 255, 255);
imagefill($dst, 0, 0, $white);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Generate a branded 1200×630 fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
$cDark = imagecolorallocate($img, 28, 28, 28);
imagefill($img, 0, 0, $cBg);
// Subtle vignette border
imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, $cDark);
imagefilledrectangle($img, 4, 4, $w - 5, $h - 5, $cBg);
// Red accent top bar
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
// Red accent bottom bar
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
// Play button circle (center)
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30;
$r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
// Play triangle
$tri = [
$cx - 22, $cy - 30,
$cx - 22, $cy + 30,
$cx + 34, $cy,
];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
// App name (top-left)
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
// Video title (wrap long titles)
$title = $video->title ?: 'Video';
$maxChars = 42;
if (mb_strlen($title) > $maxChars) {
$lines = [];
$words = explode(' ', $title);
$line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) {
$lines[] = trim($line);
$line = $word;
} else {
$line .= ($line ? ' ' : '') . $word;
}
}
if ($line) $lines[] = trim($line);
} else {
$lines = [$title];
}
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
// Channel name (below title)
$channel = $video->user?->name ?? config('app.name');
$bbox = imagettfbbox(16, 0, $fontNormal, $channel);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $channel);
// Domain (bottom-right)
$domain = parse_url(config('app.url'), PHP_URL_HOST) ?? config('app.url');
imagettftext($img, 14, 0, $w - 300, $h - 24, $cGray, $fontNormal, $domain);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
public function trackProgress(Request $request, Video $video)
{
$seconds = max(0, (int) $request->input('watched_seconds', 0));
$completed = (bool) $request->boolean('completed');
$viewDid = $request->cookie('_did');
$query = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDay())
->orderByDesc('id')
->limit(1);
if (Auth::check()) {
$query->where('user_id', Auth::id());
} else {
$query->whereNull('user_id');
if ($viewDid) {
$query->where('device_id', $viewDid);
} else {
return response()->json(['ok' => false], 204);
}
}
$view = $query->first();
if (! $view) {
return response()->json(['ok' => false], 204);
}
$updates = [];
if ($seconds > (int) $view->watched_seconds) {
$updates['watched_seconds'] = $seconds;
}
if ($completed && ! $view->completed) {
$updates['completed'] = true;
}
if ($updates) {
\DB::table('video_views')->where('id', $view->id)->update($updates);
}
return response()->json(['ok' => true]);
}
public function insights(Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$id = $video->id;
$now = now();
$totalViews = \DB::table('video_views')->where('video_id', $id)->count();
$uniqueViewers = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$viewsToday = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->startOfDay())
->count();
$viewsThisWeek = \DB::table('video_views')
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->subDays(7))
->count();
$viewsLastWeek = \DB::table('video_views')
->where('video_id', $id)
->whereBetween('watched_at', [
$now->copy()->subDays(14),
$now->copy()->subDays(7),
])
->count();
$weekChange = $viewsLastWeek > 0
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
: ($viewsThisWeek > 0 ? 100 : 0);
// 14-day daily breakdown — segmented by viewer category (male / female / other-or-guest)
$rawDaily = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("date(video_views.watched_at) as day,
sum(case when users.gender = 'male' then 1 else 0 end) as male_cnt,
sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
count(*) as cnt")
->where('video_views.video_id', $id)
->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(video_views.watched_at)")
->orderBy('day')
->get()
->keyBy('day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$key = $d->format('Y-m-d');
$row = $rawDaily->get($key);
$daily[] = [
'date' => $key,
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => $row ? (int) $row->cnt : 0,
'male' => $row ? (int) $row->male_cnt : 0,
'female' => $row ? (int) $row->female_cnt : 0,
'other' => $row ? (int) $row->other_cnt : 0,
];
}
// Country breakdown (top 10)
$rawCountries = \DB::table('video_views')
->selectRaw("country, country_name, count(*) as cnt")
->where('video_id', $id)
->whereNotNull('country')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->limit(10)
->get();
$totalGeo = $rawCountries->sum('cnt');
$countries = $rawCountries->map(fn ($c) => [
'code' => $c->country,
'name' => $c->country_name,
'count' => (int) $c->cnt,
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
])->values();
// Peak hour (0-23) across all views
$driver = \DB::getDriverName();
$hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)';
$peakRow = \DB::table('video_views')
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
->where('video_id', $id)
->groupByRaw("{$hourExpr}")
->orderByDesc('cnt')
->first();
$peakHour = $peakRow ? (int) $peakRow->hr : null;
// Top registered viewers (by view count) — also surface device/browser of their latest visit
$topViewersRaw = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get();
// One extra query: most-recent user_agent per top viewer (small, indexed lookup)
$topViewerIds = $topViewersRaw->pluck('id')->all();
$latestUaByUid = [];
if ($topViewerIds) {
$latestRows = \DB::table('video_views as v1')
->whereIn('v1.user_id', $topViewerIds)
->where('v1.video_id', $id)
->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
->get(['v1.user_id', 'v1.user_agent']);
foreach ($latestRows as $row) {
$latestUaByUid[$row->user_id] = $row->user_agent;
}
}
$topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
$ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$guestViews = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent activity — grouped by viewer.
// Registered users group by user_id. For guests we layer three signals,
// strongest first: device_hash (multi-signal browser fingerprint) → device_id
// cookie → ip_address (legacy / cookie-less). This survives cookie clears,
// incognito mode, browser swaps, and VPN / country hops.
$recentRaw = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select(
'video_views.watched_at',
'video_views.country',
'video_views.ip_address',
'video_views.device_id',
'video_views.device_hash',
'video_views.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar',
'users.username as user_channel'
)
->where('video_views.video_id', $id)
->orderByDesc('video_views.watched_at')
->limit(500)
->get();
$groups = [];
foreach ($recentRaw as $r) {
if ($r->user_id) {
$key = 'u:' . $r->user_id;
} else {
$guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
$key = 'g:' . $guestKey;
}
if (! isset($groups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$groups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->watched_at,
];
}
$groups[$key]['count']++;
// first row is the most recent (orderByDesc) so device/browser reflect their latest visit
}
$recentViewers = array_values($groups);
// Download details
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
// Per-type breakdown
$dlByType = \DB::table('video_downloads')
->selectRaw('type, count(*) as cnt')
->where('video_id', $id)
->groupBy('type')
->pluck('cnt', 'type');
// Per-user download counts (top 20 logged-in users) + device/browser of their latest download
$dlUsersRaw = \DB::table('video_downloads')
->join('users', 'users.id', '=', 'video_downloads.user_id')
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
->where('video_downloads.video_id', $id)
->whereNotNull('video_downloads.user_id')
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('cnt')
->limit(20)
->get();
$dlUserIds = $dlUsersRaw->pluck('id')->all();
$dlLatestUaByUid = [];
if ($dlUserIds) {
$latestDl = \DB::table('video_downloads as d1')
->whereIn('d1.user_id', $dlUserIds)
->where('d1.video_id', $id)
->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
->get(['d1.user_id', 'd1.user_agent']);
foreach ($latestDl as $row) {
$dlLatestUaByUid[$row->user_id] = $row->user_agent;
}
}
$dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
$ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$dlGuests = \DB::table('video_downloads')
->where('video_id', $id)
->whereNull('user_id')
->count();
// Recent download activity — grouped by downloader.
// Guests are keyed by ip_address (video_downloads doesn't track device_id);
// device/browser comes from the most-recent download's user_agent.
$dlRecentRaw = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select(
'video_downloads.type',
'video_downloads.downloaded_at',
'video_downloads.country',
'video_downloads.country_name',
'video_downloads.ip_address',
'video_downloads.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar'
)
->where('video_downloads.video_id', $id)
->orderByDesc('video_downloads.downloaded_at')
->limit(500)
->get();
$dlGroups = [];
foreach ($dlRecentRaw as $r) {
$key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
if (! isset($dlGroups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$dlGroups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'type' => $r->type,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->downloaded_at,
];
}
$dlGroups[$key]['count']++;
}
$dlRecent = array_values($dlGroups);
// Gender breakdown (authenticated viewers only)
$genderRows = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.gender, count(*) as cnt')
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.gender')
->groupBy('users.gender')
->get();
$totalGender = $genderRows->sum('cnt');
$genders = $genderRows->map(fn ($g) => [
'gender' => $g->gender,
'count' => (int) $g->cnt,
'pct' => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0,
])->values();
// Age group breakdown (authenticated viewers with birthday set)
$ageExpr = $driver === 'sqlite'
? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)"
: "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)";
$rawAges = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("{$ageExpr} as age, users.gender as gender")
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday')
->get();
$blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
$ageBuckets = [
'Under 13' => $blank, '1317' => $blank, '1824' => $blank, '2534' => $blank,
'3544' => $blank, '4554' => $blank, '5564' => $blank, '65+' => $blank,
];
foreach ($rawAges as $row) {
$age = (int) $row->age;
$bucket = match (true) {
$age < 13 => 'Under 13',
$age <= 17 => '1317',
$age <= 24 => '1824',
$age <= 34 => '2534',
$age <= 44 => '3544',
$age <= 54 => '4554',
$age <= 64 => '5564',
default => '65+',
};
$ageBuckets[$bucket]['count']++;
if ($row->gender === 'male') $ageBuckets[$bucket]['male']++;
elseif ($row->gender === 'female') $ageBuckets[$bucket]['female']++;
else $ageBuckets[$bucket]['other']++;
}
$totalAge = array_sum(array_column($ageBuckets, 'count'));
$ageGroups = collect($ageBuckets)
->filter(fn ($b) => $b['count'] > 0)
->map(fn ($b, $label) => [
'label' => $label,
'count' => $b['count'],
'male' => $b['male'],
'female' => $b['female'],
'other' => $b['other'],
'pct' => $totalAge > 0 ? round($b['count'] / $totalAge * 100) : 0,
])->values();
// Who liked this video
$likers = \DB::table('video_likes')
->join('users', 'users.id', '=', 'video_likes.user_id')
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
->where('video_likes.video_id', $id)
->orderByDesc('video_likes.created_at')
->limit(50)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'liked_at' => $u->liked_at,
]);
// ── Share analytics ────────────────────────────────────────
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
$shareIds = $shareLinks->pluck('id');
$shareReach = $shareIds->isEmpty() ? 0
: \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count();
$shareLinksCount = $shareLinks->count();
// Per-link breakdown for insights panel
$shareBreakdown = $shareLinks->map(function ($s) {
$accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();
$user = $s->user_id
? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username'])
: null;
// Most common country among accesses for this link
$topCountry = \DB::table('share_accesses')
->where('share_id', $s->id)
->whereNotNull('country')
->selectRaw('country, country_name, count(*) as cnt')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->first();
return [
'token' => $s->token,
'sharer' => $user ? $user->name : 'Guest',
'sharer_id' => $user ? $user->id : null,
'sharer_channel' => $user ? ($user->username ?? $user->id) : null,
'avatar' => $user
? ($user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id)
: null,
'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null,
'reach' => $accesses,
'created_at' => $s->created_at,
];
})->sortByDesc('reach')->values()->take(10);
// ── Skip rate ──────────────────────────────────────────────
// Skipped = watched_seconds < max(10, 10% of duration)
$duration = (int) ($video->duration ?? 0);
$skipThreshold = max(10, (int) floor($duration * 0.10));
$skippedViews = \DB::table('video_views')
->where('video_id', $id)
->where('watched_seconds', '<', $skipThreshold)
->count();
$skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;
// ── Save rate ──────────────────────────────────────────────
// Distinct users who added this video to a playlist (excluding the uploader's own playlists)
$saveCount = \DB::table('playlist_videos')
->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
->where('playlist_videos.video_id', $id)
->where('playlists.user_id', '!=', $video->user_id)
->distinct('playlists.user_id')
->count('playlists.user_id');
$uniqueViewersAll = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;
// ── Profile (wall) visits originating from this video ───────
$profileVisits = \DB::table('profile_visits')
->where('profile_user_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── New subscribers driven by this video ───────────────────
$newSubscribers = \DB::table('user_subscriptions')
->where('channel_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── Comments (including replies) ───────────────────────────
$commentsCount = \DB::table('comments')->where('video_id', $id)->count();
// ── Accounts reached (distinct users + distinct guest devices) ──
$reachedUsers = $uniqueViewersAll;
$reachedGuests = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->whereNotNull('device_id')
->distinct('device_id')
->count('device_id');
$accountsReached = $reachedUsers + $reachedGuests;
return response()->json([
'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers,
'skip_rate' => $skipRate,
'skipped_views' => $skippedViews,
'skip_threshold' => $skipThreshold,
'save_count' => $saveCount,
'save_rate' => $saveRate,
'profile_visits' => $profileVisits,
'new_subscribers' => $newSubscribers,
'comments_count' => $commentsCount,
'accounts_reached' => $accountsReached,
'reached_users' => $reachedUsers,
'reached_guests' => $reachedGuests,
'top_viewers' => $topViewers,
'guest_views' => $guestViews,
'recent_viewers' => $recentViewers,
'views_today' => $viewsToday,
'views_this_week' => $viewsThisWeek,
'views_last_week' => $viewsLastWeek,
'week_change' => $weekChange,
'downloads' => $totalDownloads,
'dl_video' => (int) ($dlByType->get('video') ?? 0),
'dl_mp3' => (int) ($dlByType->get('mp3') ?? 0),
'dl_users' => $dlUsers,
'dl_guests' => $dlGuests,
'dl_recent' => $dlRecent,
'shares' => $shareReach,
'share_links' => $shareLinksCount,
'share_breakdown' => $shareBreakdown,
'countries' => $countries,
'daily' => $daily,
'peak_hour' => $peakHour,
'likes' => $video->like_count,
'likers' => $likers,
'genders' => $genders,
'age_groups' => $ageGroups,
]);
}
// ── Drill-down: viewers from one country ──────────────────────────────
public function insightsCountry(Video $video, string $country)
{
if (Auth::id() !== $video->user_id) abort(403);
$id = $video->id;
$country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2));
$totalViews = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count();
$countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name');
$guestCount = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count();
$registeredUsers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->where('video_views.country', $country)
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
// 14-day trend for this country
$now = now();
$rawDaily = \DB::table('video_views')
->selectRaw("date(watched_at) as day, count(*) as cnt")
->where('video_id', $id)
->where('country', $country)
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(watched_at)")
->orderBy('day')
->pluck('cnt', 'day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$daily[] = ['label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0)];
}
return response()->json([
'country' => $country,
'country_name' => $countryName ?? $country,
'total_views' => $totalViews,
'registered_users' => $registeredUsers,
'guest_count' => $guestCount,
'daily' => $daily,
]);
}
// ── Drill-down: viewers on a specific day ─────────────────────────────
public function insightsDay(Video $video, string $date)
{
if (Auth::id() !== $video->user_id) abort(403);
try {
$day = \Carbon\Carbon::createFromFormat('Y-m-d', $date, config('app.timezone'));
} catch (\Throwable) {
abort(400, 'Invalid date');
}
$id = $video->id;
$start = $day->copy()->startOfDay();
$end = $day->copy()->endOfDay();
$totalViews = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->count();
$guestCount = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->whereNull('user_id')->count();
$registeredUsers = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
->whereBetween('video_views.watched_at', [$start, $end])
->whereNotNull('video_views.user_id')
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
// Hourly breakdown (0-23)
$driver = \DB::getDriverName();
$hourExpr = $driver === 'sqlite' ? "CAST(strftime('%H', watched_at) AS INTEGER)" : 'HOUR(watched_at)';
$rawHourly = \DB::table('video_views')
->selectRaw("{$hourExpr} as hr, count(*) as cnt")
->where('video_id', $id)
->whereBetween('watched_at', [$start, $end])
->groupByRaw($hourExpr)
->orderBy('hr')
->pluck('cnt', 'hr');
$hourly = [];
for ($h = 0; $h < 24; $h++) {
$hourly[] = [
'hour' => $h,
'label' => ($h === 0 ? '12am' : ($h < 12 ? $h . 'am' : ($h === 12 ? '12pm' : ($h - 12) . 'pm'))),
'count' => (int) ($rawHourly->get($h) ?? 0),
];
}
// Top countries that day
$countries = \DB::table('video_views')
->selectRaw("country, country_name, count(*) as cnt")
->where('video_id', $id)
->whereBetween('watched_at', [$start, $end])
->whereNotNull('country')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->limit(5)
->get()
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
return response()->json([
'date' => $day->format('M d, Y'),
'day_of_week' => $day->format('l'),
'total_views' => $totalViews,
'registered_users' => $registeredUsers,
'guest_count' => $guestCount,
'hourly' => $hourly,
'countries' => $countries,
]);
}
// ── Drill-down: one user's full download history on this video ────────
public function insightsDownloaderHistory(Video $video, int $userId)
{
if (Auth::id() !== $video->user_id) abort(403);
$user = \App\Models\User::findOrFail($userId);
$records = \DB::table('video_downloads')
->where('video_id', $video->id)
->where('user_id', $userId)
->orderByDesc('downloaded_at')
->get(['id', 'type', 'country', 'country_name', 'downloaded_at'])
->map(fn ($r) => [
'type' => $r->type,
'country' => $r->country,
'country_name' => $r->country_name,
'at' => $r->downloaded_at,
]);
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
],
'total' => $records->count(),
'records' => $records,
]);
}
// ── Backfill a device fingerprint after the JS computes it ────────────
// On the very first visit the cookie isn't there yet, so the view row is
// inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
// ms later with the freshly-computed hash so we can stamp it onto the row
// that was just inserted (and the future cookie does the rest).
public function identify(Request $request, Video $video)
{
$hash = (string) $request->input('hash', '');
if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
return response()->json(['ok' => false], 422);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
// Update the latest matching view row for this caller (user OR cookie OR IP)
// within a short window so we don't accidentally stamp someone else's row.
$q = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(10))
->whereNull('device_hash');
if (Auth::check()) {
$q->where('user_id', Auth::id());
} else {
$did = $request->cookie('_did');
$q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
if ($did) $w->where('device_id', $did);
$w->orWhere('ip_address', $ip);
});
}
$q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);
return response()->json(['ok' => true])
->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
}
// ── User-agent → coarse device / browser / OS labels ──────────────────
// Shared by every insights endpoint so labels stay consistent.
private function parseUserAgent(?string $ua): array
{
if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];
// Device family
$device = 'Desktop';
if (preg_match('/iPad/i', $ua)) $device = 'iPad';
elseif (preg_match('/Tablet/i', $ua)) $device = 'Tablet';
elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
elseif (preg_match('/Android/i', $ua)) $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
elseif (preg_match('/Mobile/i', $ua)) $device = 'Mobile';
// OS
$os = 'Unknown';
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows 10/11';
elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m)) $os = 'macOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m)) $os = 'Android ' . $m[1];
elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m)) $os = 'iOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m)) $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
// Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
$browser = 'Unknown';
if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m)) $browser = 'Edge ' . $m[1];
elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m)) $browser = 'Opera ' . $m[1];
elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m)) $browser = 'Firefox ' . $m[1];
elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m)) $browser = 'Chrome ' . $m[1];
elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m)) $browser = 'Safari ' . $m[1];
elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m)) $browser = 'Safari ' . $m[1];
return ['device' => $device, 'os' => $os, 'browser' => $browser];
}
// ── Drill-down: detail for one share link ─────────────────────────────
public function insightsShare(Video $video, string $token)
{
if (Auth::id() !== $video->user_id) abort(403);
$share = \DB::table('video_shares')
->where('video_id', $video->id)
->where('token', $token)
->first();
if (! $share) abort(404);
// Sharer profile
$sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
if ($share->user_id) {
$u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
if ($u) {
$sharer = [
'is_guest' => false,
'id' => $u->id,
'name' => $u->name,
'channel' => $u->username,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
];
}
}
$accesses = \DB::table('share_accesses')
->where('share_id', $share->id)
->orderByDesc('accessed_at')
->get();
// Country / device / browser / os aggregation
$countries = [];
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($accesses as $a) {
$code = $a->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
$p = $this->parseUserAgent($a->user_agent ?? null);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
$recent = $accesses->take(50)->map(function ($a) {
$p = $this->parseUserAgent($a->user_agent ?? null);
return [
'at' => $a->accessed_at,
'country' => $a->country,
'device' => $p['device'],
'browser' => $p['browser'],
];
})->values();
return response()->json([
'token' => $share->token,
'created_at' => $share->created_at,
'sharer' => $sharer,
'reach' => $accesses->count(),
'countries' => array_values($countries),
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
// ── Drill-down: one viewer's full view history on this video ─────────
// $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
public function insightsViewer(Video $video, string $who)
{
if (Auth::id() !== $video->user_id) abort(403);
$who = substr($who, 0, 80);
$isUser = str_starts_with($who, 'u:');
$key = substr($who, 2);
$q = \DB::table('video_views')->where('video_id', $video->id);
if ($isUser) {
$q->where('user_id', (int) $key);
} else {
// Match across all three guest signals: fingerprint hash > device cookie > legacy IP
$q->whereNull('user_id')->where(function ($w) use ($key) {
$w->where('device_hash', $key)
->orWhere('device_id', $key)
->orWhere('ip_address', $key);
});
}
$rows = $q->orderByDesc('watched_at')
->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);
if ($rows->isEmpty()) abort(404);
// Build identity block
$identity = [
'is_guest' => ! $isUser,
'name' => 'Guest',
'avatar' => null,
'channel' => null,
];
if ($isUser) {
$user = \App\Models\User::find((int) $key);
if ($user) {
$identity = [
'is_guest' => false,
'id' => $user->id,
'name' => $user->name,
'channel' => $user->username,
'avatar' => $user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id,
];
}
} else {
$identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
}
// Country aggregation
$countries = [];
foreach ($rows as $r) {
$code = $r->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
// Device / browser / OS — parse on the fly from user_agent
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($rows as $r) {
$p = $this->parseUserAgent($r->user_agent);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
// Recent timestamps (cap 50 for the modal)
$recent = $rows->take(50)->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
]);
return response()->json([
'identity' => $identity,
'total' => $rows->count(),
'first_at' => $rows->last()->watched_at,
'last_at' => $rows->first()->watched_at,
'countries' => $countries,
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
}