Use NAS as primary storage — direct upload when enabled
When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.
- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
thumbnailStorageKey(), localPath(), storageKey() helpers for
format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
296d605864
commit
6b3ab5b65e
11 files changed
+869
-125
No files matched your search
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
|
||||
* NAS fallback: if the file is missing locally it is fetched from the NAS and
|
||||
* cached in the standard public storage directory before being served.
|
||||
*
|
||||
* All routes are public (no auth) so images render in emails and for guests.
|
||||
*/
|
||||
class MediaController extends Controller
|
||||
{
|
||||
public function thumbnail(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
|
||||
// The thumbnail field stores the local relative path.
|
||||
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Might be a slide
|
||||
if (! file_exists($local)) {
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$dir = $nas->resolveVideoDir($slide->video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat filename format
|
||||
$local = storage_path('app/public/thumbnails/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
// Find the video that owns this thumbnail and pull from NAS
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
|
||||
if ($video) {
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$nasPath = "{$dir}/thumb.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
|
||||
// Not found via video — might be a slide
|
||||
if (! file_exists($local)) {
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$dir = $nas->resolveVideoDir($slide->video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (! file_exists($local)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function avatar(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
$local = storage_path('app/public/avatars/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$nasPath = "{$dir}/avatar.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function banner(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
$local = storage_path('app/public/banners/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
$user = User::where('banner', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$nasPath = "{$dir}/cover.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function fileResponse(string $path): Response
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'webp' => 'image/webp',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
|
||||
return response(file_get_contents($path), 200, [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -330,9 +330,9 @@ class SuperAdminController extends Controller
|
||||
|
||||
// Delete user's videos and associated files
|
||||
foreach ($user->videos as $video) {
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
Storage::delete($video->path);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
Storage::delete($video->thumbnailStorageKey());
|
||||
}
|
||||
}
|
||||
$user->videos()->delete();
|
||||
@@ -550,9 +550,9 @@ class SuperAdminController extends Controller
|
||||
]);
|
||||
|
||||
// Delete files
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
Storage::delete($video->path);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
Storage::delete($video->thumbnailStorageKey());
|
||||
}
|
||||
|
||||
// Delete likes and views - use direct queries since relationships have timestamp issues
|
||||
@@ -795,12 +795,13 @@ class SuperAdminController extends Controller
|
||||
public function settings()
|
||||
{
|
||||
$settings = [
|
||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
||||
];
|
||||
|
||||
$gpus = $this->probeGpus();
|
||||
@@ -812,12 +813,13 @@ class SuperAdminController extends Controller
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'gpu_enabled' => 'required|in:true,false',
|
||||
'gpu_device' => 'required|integer|min:0|max:15',
|
||||
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||
'ffmpeg_binary' => 'required|string|max:255',
|
||||
'gpu_enabled' => 'required|in:true,false',
|
||||
'gpu_device' => 'required|integer|min:0|max:15',
|
||||
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||
'ffmpeg_binary' => 'required|string|max:255',
|
||||
'nas_sync_enabled' => 'required|in:true,false',
|
||||
]);
|
||||
|
||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||
@@ -825,12 +827,13 @@ class SuperAdminController extends Controller
|
||||
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
||||
}
|
||||
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
@@ -254,16 +254,56 @@ class VideoController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $isAudioUpload) {
|
||||
CompressVideoJob::dispatch($video)
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
}
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
try {
|
||||
NasSyncVideoJob::dispatch($video);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
|
||||
if ($nas->isEnabled()) {
|
||||
// ── NAS-primary: push directly to NAS, delete local temp files ──
|
||||
try {
|
||||
$video->load('slides');
|
||||
|
||||
// Build slide abs-paths map for audio uploads
|
||||
$slideAbsPaths = [];
|
||||
foreach ($slideFiles as $pos => $fname) {
|
||||
$slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
|
||||
}
|
||||
|
||||
$tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
|
||||
// For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
|
||||
if ($isAudioUpload) $tempThumbAbs = null;
|
||||
|
||||
$nas->uploadDirectToNas(
|
||||
$video,
|
||||
storage_path('app/' . $path),
|
||||
$tempThumbAbs,
|
||||
$slideAbsPaths
|
||||
);
|
||||
$video->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('uploadDirectToNas failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
|
||||
if (! $isAudioUpload) {
|
||||
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
}
|
||||
} else {
|
||||
// ── Local storage: move into NAS-mirrored local directory schema ──
|
||||
try {
|
||||
$video->load('slides');
|
||||
$nas->organizeLocalFiles($video);
|
||||
$video->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('organizeLocalFiles failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Compress + HLS pipeline for local storage
|
||||
if (! $isAudioUpload) {
|
||||
CompressVideoJob::dispatch($video)
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
}
|
||||
}
|
||||
|
||||
$video->load('user');
|
||||
@@ -450,7 +490,7 @@ class VideoController extends Controller
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
||||
'id' => $s->id,
|
||||
'url' => asset('storage/thumbnails/' . $s->filename),
|
||||
'url' => $s->url,
|
||||
])->values();
|
||||
|
||||
return response()->json([
|
||||
@@ -460,7 +500,7 @@ class VideoController extends Controller
|
||||
'title' => $video->title,
|
||||
'description' => $video->description,
|
||||
'thumbnail' => $video->thumbnail,
|
||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : null,
|
||||
'thumbnail_url' => $video->thumbnail_url,
|
||||
'visibility' => $video->visibility ?? 'public',
|
||||
'type' => $video->type ?? 'generic',
|
||||
'download_access' => $video->download_access,
|
||||
@@ -492,11 +532,23 @@ class VideoController extends Controller
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
Storage::delete($video->thumbnailStorageKey());
|
||||
}
|
||||
if (str_starts_with($video->path, 'users/')) {
|
||||
// New-format video: store thumbnail in the video's local dir
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$localDir = $nas->localVideoDir($video);
|
||||
@mkdir($localDir, 0755, true);
|
||||
$ext = $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
||||
} else {
|
||||
// Legacy video: keep in flat thumbnails dir
|
||||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
|
||||
if (! isset($data['visibility'])) {
|
||||
@@ -510,8 +562,8 @@ class VideoController extends Controller
|
||||
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
||||
|
||||
// Delete slides not in the kept list
|
||||
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) {
|
||||
Storage::delete('public/thumbnails/' . $slide->filename);
|
||||
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
|
||||
Storage::delete($slide->storageKey());
|
||||
$slide->delete();
|
||||
$slidesChanged = true;
|
||||
});
|
||||
@@ -526,10 +578,27 @@ class VideoController extends Controller
|
||||
// Add new slides
|
||||
if ($request->hasFile('slides_add')) {
|
||||
$nextPos = count($keptOrder);
|
||||
$isNewFormat = str_starts_with($video->path, 'users/');
|
||||
if ($isNewFormat) {
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$localDir = $nas->localVideoDir($video);
|
||||
@mkdir("{$localDir}/slides", 0755, true);
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
||||
}
|
||||
foreach ($request->file('slides_add') as $file) {
|
||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||
$file->storeAs('public/thumbnails', $fname);
|
||||
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos++]);
|
||||
if ($isNewFormat) {
|
||||
// Create placeholder record to get the auto-increment ID
|
||||
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
||||
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
||||
} else {
|
||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||
$file->storeAs('public/thumbnails', $fname);
|
||||
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos]);
|
||||
}
|
||||
$nextPos++;
|
||||
$slidesChanged = true;
|
||||
}
|
||||
}
|
||||
@@ -601,9 +670,9 @@ class VideoController extends Controller
|
||||
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
|
||||
]);
|
||||
|
||||
Storage::delete('public/videos/'.$video->filename);
|
||||
Storage::delete($video->path);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
Storage::delete($video->thumbnailStorageKey());
|
||||
}
|
||||
|
||||
$nasSync = app(\App\Services\NasSyncService::class);
|
||||
@@ -694,7 +763,7 @@ class VideoController extends Controller
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/'.$video->filename);
|
||||
$path = $video->localVideoPath();
|
||||
|
||||
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
|
||||
if (! file_exists($path)) {
|
||||
@@ -822,7 +891,7 @@ class VideoController extends Controller
|
||||
$this->checkDownloadAccess($video);
|
||||
$this->recordDownload($video, 'video');
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
$path = $video->localVideoPath();
|
||||
|
||||
// If not on local disk, try to pull from NAS
|
||||
if (! file_exists($path)) {
|
||||
@@ -875,7 +944,7 @@ class VideoController extends Controller
|
||||
|
||||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||||
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||
$audioPath = storage_path('app/public/videos/' . $video->filename);
|
||||
$audioPath = $video->localVideoPath();
|
||||
|
||||
if (! file_exists($audioPath)) {
|
||||
return response()->json(['error' => 'Audio file not found'], 404);
|
||||
@@ -918,9 +987,7 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get();
|
||||
$validSlides = $slides->filter(fn($s) => file_exists(
|
||||
storage_path('app/public/thumbnails/' . $s->filename)
|
||||
))->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';
|
||||
@@ -941,7 +1008,7 @@ class VideoController extends Controller
|
||||
$inputs = '';
|
||||
$scaleFc = [];
|
||||
foreach ($validSlides as $i => $slide) {
|
||||
$imgPath = storage_path('app/public/thumbnails/' . $slide->filename);
|
||||
$imgPath = $slide->localPath();
|
||||
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
|
||||
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
|
||||
}
|
||||
@@ -966,7 +1033,7 @@ class VideoController extends Controller
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||
|
||||
} elseif ($validSlides->count() === 1) {
|
||||
$imgPath = storage_path('app/public/thumbnails/' . $validSlides->first()->filename);
|
||||
$imgPath = $validSlides->first()->localPath();
|
||||
$cmd = "{$ffmpeg} -y"
|
||||
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
||||
. ' -i ' . escapeshellarg($audioPath)
|
||||
@@ -1057,7 +1124,7 @@ class VideoController extends Controller
|
||||
{
|
||||
$this->checkDownloadAccess($video);
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
$path = $video->localVideoPath();
|
||||
|
||||
if (! file_exists($path)) {
|
||||
abort(404, 'Video file not found.');
|
||||
@@ -1274,8 +1341,8 @@ class VideoController extends Controller
|
||||
{
|
||||
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
|
||||
if ($video->thumbnail) {
|
||||
$path = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
||||
if (file_exists($path)) {
|
||||
$path = $video->localThumbnailPath();
|
||||
if ($path && file_exists($path)) {
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
// Load source image via GD
|
||||
$src = match($ext) {
|
||||
|
||||
Reference in new issue
Block a user