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 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-14 17:17:07 +03:00
parent 296d605864
commit 6b3ab5b65e
11 changed files with 869 additions and 125 deletions

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
@ -13,7 +14,7 @@ class NasFreeLocalStorage extends Command
{--dry-run : Preview what would be deleted without deleting}
{--force : Actually delete local files confirmed on NAS}';
protected $description = 'Delete local video files that are already stored on the NAS';
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
public function handle(NasSyncService $nas): int
{
@ -34,64 +35,188 @@ class NasFreeLocalStorage extends Command
$this->info($mode);
$this->newLine();
// Only videos that still have a local file
$videos = Video::all()->filter(function (Video $v) {
return file_exists(storage_path('app/' . $v->path));
});
if ($videos->isEmpty()) {
$this->info('No local video files found — nothing to do.');
return 0;
}
$this->info("Checking {$videos->count()} local file(s) against NAS…");
$this->newLine();
$toDelete = [];
$totalBytes = 0;
$toDelete = [];
// ── Videos ───────────────────────────────────────────────────────────
$this->info('Scanning video files…');
$videos = Video::all()->filter(fn (Video $v) => file_exists(storage_path('app/' . $v->path)));
if ($videos->isNotEmpty()) {
$bar = $this->output->createProgressBar($videos->count());
$bar->start();
foreach ($videos as $video) {
$localPath = storage_path('app/' . $video->path);
// meta.json is written last in syncVideo(), so its presence means
// the video file was fully pushed to NAS.
$dir = $nas->resolveVideoDir($video);
$meta = null;
try {
$raw = $nas->getContent("{$dir}/meta.json");
$meta = $raw ? json_decode($raw, true) : null;
} catch (\Throwable) {
// SMB error — skip this file
}
} catch (\Throwable) {}
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
$bytes = filesize($localPath);
$totalBytes += $bytes;
$toDelete[] = [
'video' => $video,
'path' => $localPath,
'bytes' => $bytes,
];
$toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes];
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->newLine();
} else {
$this->line(' No local video files found.');
}
// ── Thumbnails & slides (both legacy flat dir and new NAS-mirrored dirs) ──
$this->newLine();
$this->info('Scanning thumbnail and slide files…');
// Helper: check if a video's NAS dir is confirmed, using meta.json
$confirmNas = function (Video $v) use ($nas): bool {
try {
$raw = $nas->getContent($nas->resolveVideoDir($v) . '/meta.json');
$meta = $raw ? json_decode($raw, true) : null;
return is_array($meta) && ($meta['id'] ?? null) === $v->id;
} catch (\Throwable) { return false; }
};
// Legacy flat thumbnails dir
$thumbDir = storage_path('app/public/thumbnails');
if (is_dir($thumbDir)) {
foreach (glob($thumbDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "thumb:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
} else {
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "slide:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
}
// New NAS-mirrored dirs: storage/app/users/{username}/videos/{slug}/thumb.*
// slides/{id}.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($usersBase, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$absPath = $file->getPathname();
$relPath = ltrim(str_replace(storage_path('app'), '', $absPath), '/');
// Match thumbnail or slide by relative path stored in DB
$video = Video::where('thumbnail', $relPath)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'thumb:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
continue;
}
$slide = \App\Models\VideoSlide::where('filename', $relPath)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'slide:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning thumbnails.');
// ── Avatars ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning avatar files…');
$avatarDir = storage_path('app/public/avatars');
if (is_dir($avatarDir)) {
foreach (glob($avatarDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('avatar', $filename)->first();
if (! $user) continue;
// Confirm avatar.webp is on NAS
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
$this->line(' Done scanning avatars.');
}
// ── Banners ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning banner files…');
$bannerDir = storage_path('app/public/banners');
if (is_dir($bannerDir)) {
foreach (glob($bannerDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('banner', $filename)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
$this->line(' Done scanning banners.');
}
// ── Slideshow cache directories ───────────────────────────────────────
// The slideshow/ directory is a render cache that is always regenerated on
// demand, so its contents are safe to delete unconditionally.
$this->newLine();
$this->info('Scanning slideshow cache…');
$slideshowDir = storage_path('app/public/slideshow');
if (is_dir($slideshowDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$bytes = $file->getSize();
$totalBytes += $bytes;
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
}
$this->line(' Done scanning slideshow cache.');
}
// ── Summary ───────────────────────────────────────────────────────────
$this->newLine();
if (empty($toDelete)) {
$this->info('No local files confirmed on NAS — nothing to delete.');
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
return 0;
}
$this->table(
['ID', 'Title', 'Local file', 'Size'],
['Type', 'File', 'Size'],
array_map(fn ($row) => [
$row['video']->id,
\Illuminate\Support\Str::limit($row['video']->title, 40),
$row['label'],
basename($row['path']),
$this->humanBytes($row['bytes']),
], $toDelete)
@ -117,7 +242,7 @@ class NasFreeLocalStorage extends Command
foreach ($toDelete as $row) {
if (@unlink($row['path'])) {
$deleted++;
Log::info('nas:free-local: deleted ' . $row['path'], ['video_id' => $row['video']->id]);
Log::info('nas:free-local: deleted ' . $row['path']);
} else {
$failed++;
$this->warn("Could not delete: {$row['path']}");

View File

@ -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',
]);
}
}

View File

@ -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
@ -801,6 +801,7 @@ class SuperAdminController extends Controller
'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();
@ -818,6 +819,7 @@ class SuperAdminController extends Controller
'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';
@ -831,6 +833,7 @@ class SuperAdminController extends Controller
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.');
}

View File

@ -254,16 +254,56 @@ class VideoController extends Controller
]);
}
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
// ── NAS-primary: push directly to NAS, delete local temp files ──
try {
$video->load('slides');
// Build slide abs-paths map for audio uploads
$slideAbsPaths = [];
foreach ($slideFiles as $pos => $fname) {
$slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
}
$tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
// For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
if ($isAudioUpload) $tempThumbAbs = null;
$nas->uploadDirectToNas(
$video,
storage_path('app/' . $path),
$tempThumbAbs,
$slideAbsPaths
);
$video->refresh();
} catch (\Throwable $e) {
\Log::error('uploadDirectToNas failed: ' . $e->getMessage());
}
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
if (! $isAudioUpload) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
} else {
// ── Local storage: move into NAS-mirrored local directory schema ──
try {
$video->load('slides');
$nas->organizeLocalFiles($video);
$video->refresh();
} catch (\Throwable $e) {
\Log::error('organizeLocalFiles failed: ' . $e->getMessage());
}
// Compress + HLS pipeline for local storage
if (! $isAudioUpload) {
CompressVideoJob::dispatch($video)
->onQueue('video-processing')
->onConnection('database');
}
try {
NasSyncVideoJob::dispatch($video);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
}
$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,12 +532,24 @@ 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']);
}
}
if (! isset($data['visibility'])) {
unset($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) {
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++]);
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) {

View File

@ -38,9 +38,9 @@ class CompressVideoJob implements ShouldQueue
return;
}
// Create compressed filename
// Create compressed file alongside the original
$compressedFilename = 'compressed_' . $video->filename;
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
try {
$ffmpegConfig = Config::get('ffmpeg');

View File

@ -34,10 +34,23 @@ class GenerateHlsJob implements ShouldQueue
}
$sourcePath = storage_path('app/' . $video->path);
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
if (! file_exists($sourcePath)) {
// NAS-primary mode: file lives on NAS, download a temporary local copy
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$localCopy = $nas->ensureLocalCopy($video);
if ($localCopy) {
$sourcePath = $localCopy;
$nasDownloaded = $localCopy;
}
}
if (! file_exists($sourcePath)) {
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
return;
}
}
$hlsDir = 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
@ -149,14 +162,19 @@ class GenerateHlsJob implements ShouldQueue
'encoder' => $encoder,
]);
// NAS-primary storage: push the final compressed file to NAS (overwriting
// the uncompressed copy synced at upload time), then free local disk.
// HLS segments are kept local — they're small and serving them locally
// avoids per-segment SMB latency during HLS playback.
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
if ($nasDownloaded) {
// NAS-primary mode: video was fetched from NAS for HLS generation.
// The original is already on NAS — just delete the local temp copy.
@unlink($nasDownloaded);
} else {
// Local-storage mode: push the (compressed) file to NAS and free local disk.
// HLS segments stay local — per-segment SMB latency would hurt playback.
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
$nas->deleteLocalAssets($video);
}
}
} catch (\Exception $e) {

View File

@ -30,6 +30,7 @@ class NasSyncVideoJob implements ShouldQueue
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
$nas->deleteLocalAssets($this->video);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());

View File

@ -69,19 +69,53 @@ class Video extends Model
return $this->slides()->count() > 1;
}
// ── Local filesystem path helpers ─────────────────────────────────────────
/**
* Absolute path to the video file on local disk.
* Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…").
*/
public function localVideoPath(): string
{
return storage_path('app/' . $this->path);
}
/**
* Absolute path to the thumbnail on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (path contains a slash, e.g. users//thumb.jpg)
*/
public function localThumbnailPath(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? storage_path('app/' . $this->thumbnail)
: storage_path('app/public/thumbnails/' . $this->thumbnail);
}
/**
* Storage::delete()-compatible key for the thumbnail.
*/
public function thumbnailStorageKey(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? $this->thumbnail
: 'public/thumbnails/' . $this->thumbnail;
}
// Accessors
public function getUrlAttribute()
{
return asset('storage/videos/'.$this->filename);
}
public function getThumbnailUrlAttribute()
public function getThumbnailUrlAttribute(): ?string
{
if ($this->thumbnail) {
return asset('storage/thumbnails/'.$this->thumbnail);
return route('media.thumbnail', $this->thumbnail);
}
// Return null when no thumbnail - social platforms will use their own preview
return null;
}

View File

@ -15,6 +15,28 @@ class VideoSlide extends Model
public function getUrlAttribute(): string
{
return asset('storage/thumbnails/' . $this->filename);
return route('media.thumbnail', $this->filename);
}
/**
* Absolute path to the slide image on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (filename contains a slash)
*/
public function localPath(): string
{
return str_contains($this->filename, '/')
? storage_path('app/' . $this->filename)
: storage_path('app/public/thumbnails/' . $this->filename);
}
/**
* Storage::delete()-compatible key for this slide file.
*/
public function storageKey(): string
{
return str_contains($this->filename, '/')
? $this->filename
: 'public/thumbnails/' . $this->filename;
}
}

View File

@ -109,6 +109,138 @@ class NasSyncService
return null;
}
// ── Local directory layout (mirrors NAS schema) ───────────────────────────
/**
* Absolute path to the local directory for a video, mirroring the NAS structure:
* storage/app/users/{username}/videos/{title-slug}/
*
* If the video is already organised (video->path starts with "users/"), the dir
* is derived directly from the stored path so title renames never relocate files.
* Otherwise a free slug-based slot is found (same logic as resolveVideoDir but local).
*/
public function localVideoDir(Video $video): string
{
// Already organised — derive from path
if (str_starts_with($video->path, 'users/')) {
return dirname(storage_path('app/' . $video->path));
}
$video->loadMissing('user');
$userSlug = $this->userSlug($video->user);
$base = storage_path("app/users/{$userSlug}/videos");
$titleSlug = $this->titleSlug($video->title);
for ($i = 1; $i <= 50; $i++) {
$candidate = $i === 1 ? $titleSlug : "{$titleSlug}-{$i}";
$dir = "{$base}/{$candidate}";
if (! is_dir($dir)) {
return $dir; // free slot
}
$metaFile = "{$dir}/meta.json";
if (file_exists($metaFile)) {
$meta = @json_decode(file_get_contents($metaFile), true);
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
return $dir; // already belongs to this video
}
}
}
return "{$base}/{$titleSlug}-{$video->id}";
}
/**
* Move a freshly uploaded video's files into the NAS-mirrored local directory
* structure and update the DB record paths.
*
* Call this immediately after Video::create() in the upload flow.
* The method is idempotent: if the video is already organised it does nothing.
*/
public function organizeLocalFiles(Video $video): void
{
// Already organised
if (str_starts_with($video->path, 'users/')) return;
$video->loadMissing(['user', 'slides']);
$dir = $this->localVideoDir($video);
$fileSlug = $this->titleSlug($video->title);
@mkdir($dir, 0755, true);
$userSlug = $this->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($dir);
$updates = [];
// ── Video file ───────────────────────────────────────────────────
$oldVideoPath = storage_path('app/' . $video->path);
if (file_exists($oldVideoPath)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
$newFileName = "{$fileSlug}.{$ext}";
rename($oldVideoPath, "{$dir}/{$newFileName}");
$updates['path'] = "{$relDir}/{$newFileName}";
$updates['filename'] = $newFileName;
}
// ── Slides (process first; for audio, thumbnail IS slide 0) ──────
$firstSlideRelPath = null;
if ($video->slides->isNotEmpty()) {
@mkdir("{$dir}/slides", 0755, true);
foreach ($video->slides->sortBy('position') as $slide) {
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
if (! file_exists($oldSlidePath)) continue;
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$newSlideName = "{$slide->id}.{$ext}";
rename($oldSlidePath, "{$dir}/slides/{$newSlideName}");
$newSlideFilename = "{$relDir}/slides/{$newSlideName}";
$slide->update(['filename' => $newSlideFilename]);
if ($firstSlideRelPath === null) {
$firstSlideRelPath = $newSlideFilename;
}
}
// For audio uploads the thumbnail is the first slide
if ($firstSlideRelPath !== null) {
$updates['thumbnail'] = $firstSlideRelPath;
}
}
// ── Standalone thumbnail (video uploads, no slides) ──────────────
if ($video->thumbnail && ! isset($updates['thumbnail'])) {
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($oldThumbPath)) {
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
$newThumbName = "thumb.{$ext}";
rename($oldThumbPath, "{$dir}/{$newThumbName}");
$updates['thumbnail'] = "{$relDir}/{$newThumbName}";
}
}
// ── meta.json (enables localVideoDir to identify this dir later) ─
$this->writeLocalMeta($video, $dir);
if (! empty($updates)) {
$video->update($updates);
}
}
/**
* Write a meta.json to the local video directory so localVideoDir() can
* resolve the folder even after a title rename.
*/
public function writeLocalMeta(Video $video, string $dir): void
{
$meta = json_encode([
'id' => $video->id,
'user_id' => $video->user_id,
'title' => $video->title,
'created_at' => $video->created_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
@file_put_contents("{$dir}/meta.json", $meta);
}
// ── Local-cache helpers (used when NAS is the primary storage) ───────────
/**
@ -173,6 +305,39 @@ class NasSyncService
return $cachePath;
}
/**
* Ensure a local asset file exists, downloading it from the NAS if missing.
* Used by MediaController to serve thumbnails, avatars, and banners with a NAS fallback.
*
* Returns true if the file is available locally after the call.
*/
public function ensureLocalAsset(string $localPath, string $nasPath): bool
{
if (file_exists($localPath)) return true;
$dir = dirname($localPath);
if (! is_dir($dir)) @mkdir($dir, 0755, true);
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
exec('smbclient ' . $target . ' -U ' . $cred
. ' -c ' . escapeshellarg('get "' . $nasPath . '" "' . $localPath . '"')
. ' 2>&1', $out, $code);
if ($code !== 0 || ! file_exists($localPath)) {
@unlink($localPath);
Log::warning('NAS: failed to fetch asset', [
'nas_path' => $nasPath,
'output' => implode(' ', $out),
]);
return false;
}
return true;
}
/**
* Delete the local video file after it has been successfully pushed to NAS.
*/
@ -185,6 +350,110 @@ class NasSyncService
}
}
/**
* Delete local thumbnail and slide images after they have been pushed to NAS.
* MediaController will re-fetch them from NAS on demand via ensureLocalAsset().
*/
public function deleteLocalAssets(Video $video): void
{
$thumb = $video->localThumbnailPath();
if ($thumb && file_exists($thumb)) {
@unlink($thumb);
}
$video->loadMissing('slides');
foreach ($video->slides as $slide) {
$path = $slide->localPath();
if (file_exists($path)) {
@unlink($path);
}
}
Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
}
// ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────
/**
* Push a freshly uploaded video directly to the NAS without keeping a local copy.
*
* Called from VideoController::store() when NAS is enabled.
* Updates video->path, video->filename, video->thumbnail, and status in the DB.
*
* @param Video $video
* @param string $tempVideoPath Absolute path to the temporary local video file
* @param string|null $tempThumbPath Absolute path to the temporary thumbnail (may be null)
* @param array $slideAbsPaths [ position => absPath ] for audio slides
*/
public function uploadDirectToNas(
Video $video,
string $tempVideoPath,
?string $tempThumbPath,
array $slideAbsPaths = []
): void {
$video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video
$fileSlug = $this->titleSlug($video->title);
$this->mkdirp($dir);
$updates = [];
// ── Video file ───────────────────────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) {
$this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
@unlink($tempVideoPath);
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
$updates['filename'] = "{$fileSlug}.{$ext}";
}
// ── Slides (audio uploads — thumbnail IS the first slide) ────────
$firstSlideNasPath = null;
if (! empty($slideAbsPaths)) {
$this->mkdirp("{$dir}/slides");
foreach ($video->slides->sortBy('position') as $slide) {
$absPath = $slideAbsPaths[$slide->position] ?? null;
if (! $absPath || ! file_exists($absPath)) continue;
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
$nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}";
$this->putFile($absPath, $nasSlideFile);
@unlink($absPath);
$slideRelPath = "{$dir}/slides/{$slide->position}.{$slideExt}";
$slide->update(['filename' => $slideRelPath]);
if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath;
}
}
if ($firstSlideNasPath !== null) {
$updates['thumbnail'] = $firstSlideNasPath;
}
}
// ── Standalone thumbnail (video uploads without slides) ──────────
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
$this->putFile($tempThumbPath, "{$dir}/thumb.webp");
@unlink($tempThumbPath);
$updates['thumbnail'] = "{$dir}/thumb.webp";
}
// ── meta.json ────────────────────────────────────────────────────
$this->putContent(json_encode([
'id' => $video->id,
'user_id' => $video->user_id,
'title' => $video->title,
'created_at' => $video->created_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
// File is now on NAS and accessible — mark as ready
$updates['status'] = 'ready';
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $dir]);
$video->update($updates);
}
// ── High-level sync methods ───────────────────────────────────────────────
public function syncVideo(Video $video): void
@ -202,11 +471,22 @@ class NasSyncService
}
// thumb.webp
if ($video->thumbnail) {
$localThumb = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($localThumb)) {
$localThumb = $video->localThumbnailPath();
if ($localThumb && file_exists($localThumb)) {
$this->putFile($localThumb, "{$dir}/thumb.webp");
}
// slides/{position}.{ext}
$video->loadMissing('slides');
if ($video->slides->isNotEmpty()) {
$this->mkdirp("{$dir}/slides");
foreach ($video->slides as $slide) {
$localSlide = $slide->localPath();
if (file_exists($localSlide)) {
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$this->putFile($localSlide, "{$dir}/slides/{$slide->position}.{$ext}");
}
}
}
// meta.json (always written last so readMeta can find the folder)
@ -365,7 +645,7 @@ class NasSyncService
private function cfg(): array
{
return config('nas-file-manager.connection', []);
return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
}
private function smbTarget(array $cfg): string

View File

@ -317,6 +317,51 @@
</div>
</div>
{{-- ── NAS Storage ───────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-hdd-network"></i>
NAS Storage
@if($settings['nas_sync_enabled'] === 'true')
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
@else
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
@endif
</div>
<div class="settings-section-body">
<div class="setting-row" style="padding-top:0;">
<div class="setting-label">
<strong>Use NAS as primary storage</strong>
<small>
When enabled, uploads go <strong>directly to the NAS</strong> no permanent local copy is kept.
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
Thumbnails and video are fetched from NAS on demand for streaming and playback.
When disabled, files are stored in local storage using the same directory schema.
Requires the NAS connection to be configured under
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="nasSyncLabel">
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
value="{{ $settings['nas_sync_enabled'] }}">
</div>
</div>
</div>
</div>
{{-- ── Save ─────────────────────────────────────────────────── --}}
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
@ -347,6 +392,15 @@ function selectEncoder(el) {
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
// ── NAS sync toggle ───────────────────────────────────────────
const nasToggle = document.getElementById('nasSyncInput');
const nasHidden = document.getElementById('nasSyncHidden');
const nasLabel = document.getElementById('nasSyncLabel');
nasToggle.addEventListener('change', () => {
nasHidden.value = nasToggle.checked ? 'true' : 'false';
nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled';
});
// ── GPU toggle ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');