From 0b75acec8903e5b00bfb5acbd2ba7c5b45c32f32 Mon Sep 17 00:00:00 2001 From: ghassan Date: Thu, 14 May 2026 01:56:55 +0300 Subject: [PATCH] Make NAS the primary storage when enabled (not a mirror) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When NAS sync is enabled: - Audio uploads: pushed to NAS via NasSyncVideoJob, local file deleted immediately after - Video uploads: processed locally (ffprobe, compress, HLS), then at the end of GenerateHlsJob the final compressed file is re-synced to NAS and the local copy removed - stream() and download(): if local file is missing, pull from NAS into a local stream cache (storage/app/nas_cache/videos/) and serve from there with full byte-range support — so seeking still works over NAS-sourced files When NAS is disabled: - Upload, stream, and download all use local storage exclusively (no change) HLS segments are intentionally kept local: they are small, generated on-demand, and serving them via per-segment SMB round-trips would hurt playback performance. Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/VideoController.php | 33 +- app/Jobs/GenerateHlsJob.php | 10 + app/Jobs/NasSyncVideoJob.php | 38 +++ app/Services/NasSyncService.php | 381 +++++++++++++++++++++++ 4 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 app/Jobs/NasSyncVideoJob.php create mode 100644 app/Services/NasSyncService.php diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 951a7cc..00b2f4e 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Jobs\CompressVideoJob; +use App\Jobs\NasSyncVideoJob; use App\Mail\NewVideoNotification; use App\Mail\VideoUploaded; use App\Notifications\NewVideoUploaded as NewVideoUploadedNotification; @@ -259,6 +260,12 @@ class VideoController extends Controller ->onConnection('database'); } + try { + NasSyncVideoJob::dispatch($video); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage()); + } + $video->load('user'); $userEmail = Auth::user()->email; @@ -542,6 +549,12 @@ class VideoController extends Controller $video->update($data); + try { + NasSyncVideoJob::dispatch($video->fresh()); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage()); + } + AuditLog::record('video.updated', [ 'subject_type' => 'Video', 'subject_id' => (string) $video->id, @@ -592,6 +605,12 @@ class VideoController extends Controller if ($video->thumbnail) { Storage::delete('public/thumbnails/'.$video->thumbnail); } + + $nasSync = app(\App\Services\NasSyncService::class); + if ($nasSync->isEnabled()) { + $nasSync->deleteVideo($video); + } + $video->delete(); if ($request->expectsJson() || $request->ajax()) { @@ -677,8 +696,13 @@ class VideoController extends Controller $path = storage_path('app/public/videos/'.$video->filename); + // If not on local disk, try to pull from NAS (primary storage when NAS is enabled) if (! file_exists($path)) { - abort(404, 'Video file not found'); + $nas = app(\App\Services\NasSyncService::class); + $path = $nas->ensureLocalCopy($video); + if (! $path) { + abort(404, 'Video file not found'); + } } $fileSize = filesize($path); @@ -800,8 +824,13 @@ class VideoController extends Controller $path = storage_path('app/public/videos/' . $video->filename); + // If not on local disk, try to pull from NAS if (! file_exists($path)) { - abort(404, 'Video file not found.'); + $nas = app(\App\Services\NasSyncService::class); + $path = $nas->ensureLocalCopy($video); + if (! $path) { + abort(404, 'Video file not found.'); + } } // Already a video — serve directly, no conversion needed diff --git a/app/Jobs/GenerateHlsJob.php b/app/Jobs/GenerateHlsJob.php index f5accdd..9aebff2 100644 --- a/app/Jobs/GenerateHlsJob.php +++ b/app/Jobs/GenerateHlsJob.php @@ -149,6 +149,16 @@ 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); + } + } catch (\Exception $e) { Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]); Storage::deleteDirectory($hlsDir); diff --git a/app/Jobs/NasSyncVideoJob.php b/app/Jobs/NasSyncVideoJob.php new file mode 100644 index 0000000..4271223 --- /dev/null +++ b/app/Jobs/NasSyncVideoJob.php @@ -0,0 +1,38 @@ +isEnabled()) return; + try { + $nas->syncVideo($this->video); + + // Audio/music uploads have no further processing jobs, so it's safe + // to remove the local file immediately after a successful NAS push. + // Video uploads must keep the local file until GenerateHlsJob finishes. + if ($this->video->type === 'music') { + $nas->deleteLocalVideo($this->video); + } + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage()); + } + } +} diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php new file mode 100644 index 0000000..50dedb9 --- /dev/null +++ b/app/Services/NasSyncService.php @@ -0,0 +1,381 @@ +cfg()['host']); + } + + // ── Slug helpers ────────────────────────────────────────────────────────── + + public function userSlug(User $user): string + { + return $user->username ?: (string) $user->id; + } + + public function titleSlug(string $title): string + { + $slug = mb_strtolower($title); + $slug = preg_replace('/[^a-z0-9]+/u', '-', $slug); + $slug = trim($slug, '-'); + return $slug ?: 'video'; + } + + /** + * Return the NAS directory for a video. + * + * On first sync → pick a conflict-free slug and create the folder. + * On subsequent → find the existing folder by matching video ID in meta.json + * so renames of the title never lose the folder. + */ + public function resolveVideoDir(Video $video): string + { + $video->loadMissing('user'); + $userSlug = $this->userSlug($video->user); + $base = "users/{$userSlug}/videos"; + $titleSlug = $this->titleSlug($video->title); + + // 1. Try the current title slug and numbered variants (-2, -3 …) + // If any of them already hold meta.json with our video ID → reuse it. + for ($serial = 1; $serial <= 50; $serial++) { + $candidate = $serial === 1 ? $titleSlug : "{$titleSlug}-{$serial}"; + $meta = $this->readMeta("{$base}/{$candidate}"); + + if ($meta === null) { + // Folder is free — this is where we'll write + return "{$base}/{$candidate}"; + } + + if (($meta['id'] ?? null) === $video->id) { + // This folder already belongs to our video + return "{$base}/{$candidate}"; + } + } + + // 2. Fallback: scan all folders in the user's videos directory + $found = $this->scanForVideoDir($base, $video->id); + if ($found) return $found; + + // 3. Last resort: use title slug + video ID suffix (guaranteed unique) + return "{$base}/{$titleSlug}-{$video->id}"; + } + + /** + * Read meta.json from a NAS directory. Returns decoded array or null. + */ + private function readMeta(string $dir): ?array + { + $content = $this->getContent("{$dir}/meta.json"); + if ($content === null) return null; + $decoded = json_decode($content, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * List all subdirectories under $base and find the one whose + * meta.json carries the given video ID. + */ + private function scanForVideoDir(string $base, int $videoId): ?string + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + + $smbPath = str_replace('/', '\\', $base) . '\\*'; + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output); + + foreach ($output as $line) { + if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue; + $name = trim($m[1]); + if ($name === '.' || $name === '..') continue; + + $meta = $this->readMeta("{$base}/{$name}"); + if (($meta['id'] ?? null) === $videoId) { + return "{$base}/{$name}"; + } + } + + return null; + } + + // ── Local-cache helpers (used when NAS is the primary storage) ─────────── + + /** + * Absolute path where a NAS video is cached locally for HTTP streaming. + * Separate from public storage so it's never accidentally served statically. + */ + public function localCachePath(Video $video): string + { + return storage_path('app/nas_cache/videos/' . $video->filename); + } + + /** + * Ensure the video file is available at a local path for streaming. + * + * Priority: + * 1. Regular local storage (video uploaded while NAS was off, or not yet cleaned) + * 2. NAS stream cache (previously downloaded copy) + * 3. Download from NAS (first-time stream after NAS-primary upload) + * + * Returns the local absolute path, or null if unavailable. + */ + public function ensureLocalCopy(Video $video): ?string + { + // 1. Regular local storage + $regularPath = storage_path('app/' . $video->path); + if (file_exists($regularPath)) return $regularPath; + + // 2. Existing stream cache + $cachePath = $this->localCachePath($video); + if (file_exists($cachePath)) return $cachePath; + + // 3. Download from NAS + if (! $this->isEnabled()) return null; + + $cacheDir = dirname($cachePath); + if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true); + + $nasDir = $this->resolveVideoDir($video); + $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; + $fileSlug = $this->titleSlug($video->title); + $nasFile = "{$nasDir}/{$fileSlug}.{$ext}"; + + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + + exec('smbclient ' . $target . ' -U ' . $cred + . ' -c ' . escapeshellarg('get "' . $nasFile . '" "' . $cachePath . '"') + . ' 2>&1', $out, $code); + + if ($code !== 0 || ! file_exists($cachePath)) { + @unlink($cachePath); + Log::warning('NAS: failed to cache video for streaming', [ + 'video_id' => $video->id, + 'nas_path' => $nasFile, + 'output' => implode(' ', $out), + ]); + return null; + } + + Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]); + return $cachePath; + } + + /** + * Delete the local video file after it has been successfully pushed to NAS. + */ + public function deleteLocalVideo(Video $video): void + { + $path = storage_path('app/' . $video->path); + if (file_exists($path)) { + @unlink($path); + Log::info('NAS: local video removed after NAS push', ['video_id' => $video->id]); + } + } + + // ── High-level sync methods ─────────────────────────────────────────────── + + public function syncVideo(Video $video): void + { + $video->loadMissing('user'); + $dir = $this->resolveVideoDir($video); + $fileSlug = $this->titleSlug($video->title); + $this->mkdirp($dir); + + // {title-slug}.{ext} + $localVideo = storage_path('app/' . $video->path); + if (file_exists($localVideo)) { + $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; + $this->putFile($localVideo, "{$dir}/{$fileSlug}.{$ext}"); + } + + // thumb.webp + if ($video->thumbnail) { + $localThumb = storage_path('app/public/thumbnails/' . $video->thumbnail); + if (file_exists($localThumb)) { + $this->putFile($localThumb, "{$dir}/thumb.webp"); + } + } + + // meta.json (always written last so readMeta can find the folder) + $this->putContent(json_encode([ + 'id' => $video->id, + 'user_id' => $video->user_id, + 'title' => $video->title, + 'description' => $video->description, + 'type' => $video->type, + 'visibility' => $video->visibility, + 'status' => $video->status, + 'duration' => $video->duration, + 'width' => $video->width, + 'height' => $video->height, + 'orientation' => $video->orientation, + 'mime_type' => $video->mime_type, + 'size' => $video->size, + 'created_at' => $video->created_at?->toIso8601String(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json"); + } + + public function syncVideoMeta(Video $video): void + { + $video->loadMissing('user'); + $dir = $this->resolveVideoDir($video); + $this->mkdirp($dir); + + $this->putContent(json_encode([ + 'id' => $video->id, + 'user_id' => $video->user_id, + 'title' => $video->title, + 'description' => $video->description, + 'type' => $video->type, + 'visibility' => $video->visibility, + 'status' => $video->status, + 'duration' => $video->duration, + 'width' => $video->width, + 'height' => $video->height, + 'orientation' => $video->orientation, + 'mime_type' => $video->mime_type, + 'size' => $video->size, + 'updated_at' => $video->updated_at?->toIso8601String(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json"); + } + + public function deleteVideo(Video $video): void + { + $video->loadMissing('user'); + $dir = $this->resolveVideoDir($video); + $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; + + $this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}"); + $this->deleteFile("{$dir}/thumb.webp"); + $this->deleteFile("{$dir}/meta.json"); + $this->deleteFile("{$dir}/view-log.json"); + $this->deleteFile("{$dir}/edit-log.json"); + $this->deleteFolder($dir); + } + + public function syncAvatar(User $user, string $localAbsPath): void + { + $dir = "users/{$this->userSlug($user)}/profile"; + $this->mkdirp($dir); + $this->putFile($localAbsPath, "{$dir}/avatar.webp"); + } + + public function syncCover(User $user, string $localAbsPath): void + { + $dir = "users/{$this->userSlug($user)}/profile"; + $this->mkdirp($dir); + $this->putFile($localAbsPath, "{$dir}/cover.webp"); + } + + // ── SMB primitives ──────────────────────────────────────────────────────── + + public function mkdirp(string $path): void + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + + $parts = array_values(array_filter(explode('/', str_replace('\\', '/', $path)))); + $cmds = []; + $built = ''; + foreach ($parts as $part) { + $built = $built ? "{$built}/{$part}" : $part; + $cmds[] = 'mkdir "' . $built . '"'; + } + + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg(implode('; ', $cmds)) . ' 2>&1'); + // Intentionally ignore exit code — NT_STATUS_OBJECT_NAME_COLLISION means dir exists + } + + public function putFile(string $localAbsPath, string $nasRelPath): bool + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + $cmd = 'put "' . $localAbsPath . '" "' . $nasRelPath . '"'; + + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code); + + if ($code !== 0) { + Log::warning('NAS putFile failed [' . $nasRelPath . ']: ' . implode(' ', $output)); + } + + return $code === 0; + } + + public function putContent(string $content, string $nasRelPath): bool + { + $tmp = tempnam(sys_get_temp_dir(), 'nassync_'); + file_put_contents($tmp, $content); + $ok = $this->putFile($tmp, $nasRelPath); + @unlink($tmp); + return $ok; + } + + public function getContent(string $nasRelPath): ?string + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + $tmp = tempnam(sys_get_temp_dir(), 'nasget_'); + $cmd = 'get "' . $nasRelPath . '" "' . $tmp . '"'; + + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code); + + if ($code !== 0 || ! file_exists($tmp)) { + @unlink($tmp); + return null; + } + + $content = file_get_contents($tmp); + @unlink($tmp); + return $content !== false ? $content : null; + } + + public function deleteFile(string $nasRelPath): void + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1'); + } + + public function deleteFolder(string $nasRelPath): void + { + $cfg = $this->cfg(); + $target = escapeshellarg($this->smbTarget($cfg)); + $cred = escapeshellarg($this->smbCredential($cfg)); + exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1'); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function cfg(): array + { + return config('nas-file-manager.connection', []); + } + + private function smbTarget(array $cfg): string + { + return '//' . $cfg['host'] . '/' . trim($cfg['smb_share'] ?? '', '/'); + } + + private function smbCredential(array $cfg): string + { + $domain = ! empty($cfg['smb_domain']) ? $cfg['smb_domain'] . '/' : ''; + return $domain . ($cfg['username'] ?? '') . '%' . ($cfg['password'] ?? ''); + } +}