From 6b3ab5b65e4add9299f20c87eb8092ee7de59f19 Mon Sep 17 00:00:00 2001 From: ghassan Date: Thu, 14 May 2026 17:17:07 +0300 Subject: [PATCH] =?UTF-8?q?Use=20NAS=20as=20primary=20storage=20=E2=80=94?= =?UTF-8?q?=20direct=20upload=20when=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Console/Commands/NasFreeLocalStorage.php | 223 +++++++++++--- app/Http/Controllers/MediaController.php | 140 +++++++++ app/Http/Controllers/SuperAdminController.php | 47 +-- app/Http/Controllers/VideoController.php | 133 ++++++-- app/Jobs/CompressVideoJob.php | 4 +- app/Jobs/GenerateHlsJob.php | 38 ++- app/Jobs/NasSyncVideoJob.php | 1 + app/Models/Video.php | 40 ++- app/Models/VideoSlide.php | 24 +- app/Services/NasSyncService.php | 290 +++++++++++++++++- resources/views/admin/settings.blade.php | 54 ++++ 11 files changed, 869 insertions(+), 125 deletions(-) create mode 100644 app/Http/Controllers/MediaController.php diff --git a/app/Console/Commands/NasFreeLocalStorage.php b/app/Console/Commands/NasFreeLocalStorage.php index ab1e8bf..96c33a8 100644 --- a/app/Console/Commands/NasFreeLocalStorage.php +++ b/app/Console/Commands/NasFreeLocalStorage.php @@ -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)); - }); + $totalBytes = 0; + $toDelete = []; - if ($videos->isEmpty()) { - $this->info('No local video files found — nothing to do.'); - return 0; + // ── 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); + $dir = $nas->resolveVideoDir($video); + $meta = null; + try { + $raw = $nas->getContent("{$dir}/meta.json"); + $meta = $raw ? json_decode($raw, true) : null; + } catch (\Throwable) {} + + if (is_array($meta) && ($meta['id'] ?? null) === $video->id) { + $bytes = filesize($localPath); + $totalBytes += $bytes; + $toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes]; + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } else { + $this->line(' No local video files found.'); } - $this->info("Checking {$videos->count()} local file(s) against NAS…"); + // ── 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(); - $toDelete = []; - $totalBytes = 0; - $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 - } - - if (is_array($meta) && ($meta['id'] ?? null) === $video->id) { - $bytes = filesize($localPath); - $totalBytes += $bytes; - $toDelete[] = [ - 'video' => $video, - 'path' => $localPath, - 'bytes' => $bytes, - ]; - } - - $bar->advance(); - } - - $bar->finish(); - $this->newLine(2); - 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']}"); diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php new file mode 100644 index 0000000..76b5927 --- /dev/null +++ b/app/Http/Controllers/MediaController.php @@ -0,0 +1,140 @@ +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', + ]); + } +} diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php index 2c14225..5b29208 100644 --- a/app/Http/Controllers/SuperAdminController.php +++ b/app/Http/Controllers/SuperAdminController.php @@ -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.'); } diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 00b2f4e..1882d8b 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -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) { diff --git a/app/Jobs/CompressVideoJob.php b/app/Jobs/CompressVideoJob.php index fc95f39..bf1194a 100644 --- a/app/Jobs/CompressVideoJob.php +++ b/app/Jobs/CompressVideoJob.php @@ -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'); diff --git a/app/Jobs/GenerateHlsJob.php b/app/Jobs/GenerateHlsJob.php index 9aebff2..fe5d1a5 100644 --- a/app/Jobs/GenerateHlsJob.php +++ b/app/Jobs/GenerateHlsJob.php @@ -33,10 +33,23 @@ class GenerateHlsJob implements ShouldQueue return; } - $sourcePath = storage_path('app/' . $video->path); - if (!file_exists($sourcePath)) { - Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]); - return; + $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; @@ -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()) { - $nas->syncVideo($video); - $nas->deleteLocalVideo($video); + 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) { diff --git a/app/Jobs/NasSyncVideoJob.php b/app/Jobs/NasSyncVideoJob.php index 4271223..4f9b6d1 100644 --- a/app/Jobs/NasSyncVideoJob.php +++ b/app/Jobs/NasSyncVideoJob.php @@ -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()); diff --git a/app/Models/Video.php b/app/Models/Video.php index b738728..54d4042 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -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; } diff --git a/app/Models/VideoSlide.php b/app/Models/VideoSlide.php index 54a4d42..267f137 100644 --- a/app/Models/VideoSlide.php +++ b/app/Models/VideoSlide.php @@ -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; } } diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php index 50dedb9..eea3573 100644 --- a/app/Services/NasSyncService.php +++ b/app/Services/NasSyncService.php @@ -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,10 +471,21 @@ class NasSyncService } // thumb.webp - if ($video->thumbnail) { - $localThumb = storage_path('app/public/thumbnails/' . $video->thumbnail); - if (file_exists($localThumb)) { - $this->putFile($localThumb, "{$dir}/thumb.webp"); + $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}"); + } } } @@ -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 diff --git a/resources/views/admin/settings.blade.php b/resources/views/admin/settings.blade.php index 41f200b..9ec62d1 100644 --- a/resources/views/admin/settings.blade.php +++ b/resources/views/admin/settings.blade.php @@ -317,6 +317,51 @@ +{{-- ── NAS Storage ───────────────────────────────────────────── --}} +
+
+ + NAS Storage + @if($settings['nas_sync_enabled'] === 'true') + Enabled + @else + Disabled + @endif +
+
+ +
+
+ Use NAS as primary storage + + When enabled, uploads go directly to the NAS — no permanent local copy is kept. + Files are stored at users/{username}/videos/{title-slug}/ 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 + NAS Storage. + +
+
+ + +
+
+ +
+
+ {{-- ── Save ─────────────────────────────────────────────────── --}}
Cancel @@ -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');