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 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) ─────────── /** * 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; } /** * 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. */ 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]); } } /** * 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 { $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 $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) $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 app(\P7H\NasFileManager\NasStorageService::class)->cfg(); } 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'] ?? ''); } }