cfg()['host']); if (! $configured) return false; // Cache reachability for 2 minutes so a down NAS doesn't block every request. // When NAS comes back online the cache naturally expires and uploads resume. return \Illuminate\Support\Facades\Cache::remember('nas_reachable', 120, function () { $host = $this->cfg()['host'] ?? null; if (! $host) return false; $sock = @fsockopen($host, 445, $errno, $errstr, 2); if ($sock) { fclose($sock); return true; } return false; }); } /** * Flush the cached reachability flag so the next isEnabled() call re-tests. * Call this after the admin toggles the NAS setting or from tests. */ public function flushReachabilityCache(): void { \Illuminate\Support\Facades\Cache::forget('nas_reachable'); } // ── Slug helpers ────────────────────────────────────────────────────────── public function userSlug(User $user): string { return $user->username ?: (string) $user->id; } public function titleSlug(string $title): string { // NFC normalisation ensures consistent codepoints across sources $title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title; // Keep all Unicode letters and numbers; replace everything else with dashes. // This preserves Chinese, Arabic, Japanese, Cyrillic, etc. as-is. // Characters illegal in SMB/Windows filenames (\ / : * ? " < > |) are all // excluded by \p{L}\p{N}, so the result is always filesystem-safe. $slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title); $slug = mb_strtolower($slug); $slug = trim($slug, '-'); // Cap at 100 chars to stay safely within any filesystem path limit if (mb_strlen($slug) > 100) { $slug = rtrim(mb_substr($slug, 0, 100), '-'); } 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/')) { $dir = dirname(storage_path('app/' . $video->path)); // If the primary file lives inside a 'tracks/' subfolder (promoted track), // go up one extra level to reach the video root directory. if (basename($dir) === 'tracks') { $dir = dirname($dir); } return $dir; } $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; // When the video's path is a full NAS-relative path (starts with 'users/'), use it // directly — this handles promoted tracks whose path is e.g. 'users/…/tracks/14.mp3' // rather than the standard 'users/…/title-slug.mp3' layout. if (str_starts_with($video->path, 'users/')) { $this->ensureLocalAsset($regularPath, $video->path); if (file_exists($regularPath)) { Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]); return $regularPath; } Log::warning('NAS: failed to cache video for streaming', [ 'video_id' => $video->id, 'nas_path' => $video->path, ]); 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; } /** * Resolve a secondary audio track to a readable local path. * * Mirrors ensureLocalCopy() for the primary file so a demoted/secondary track * (e.g. the old primary after a language swap) streams with the same robustness: * 1. Regular local storage (file still at the track's stored path) * 2. NAS stream cache (downloaded earlier, e.g. while it was the primary) * 3. Download from NAS (when NAS is reachable) * * Returns the local absolute path, or null if the file cannot be located. */ public function ensureLocalTrackCopy(VideoAudioTrack $track): ?string { // 1. Regular local storage $regularPath = storage_path('app/' . $track->path); if (file_exists($regularPath)) return $regularPath; // 2. Existing NAS stream cache (keyed by filename, shared with the primary path) $cachePath = storage_path('app/nas_cache/videos/' . $track->filename); if (file_exists($cachePath)) { Log::info('NAS: audio track served from stream cache', [ 'track_id' => $track->id, 'video_id' => $track->video_id, 'cache' => $cachePath, ]); return $cachePath; } // 3. Download from NAS if (! $this->isEnabled()) { Log::warning('NAS: audio track unavailable (NAS disabled, no local/cache copy)', [ 'track_id' => $track->id, 'video_id' => $track->video_id, 'path' => $track->path, ]); return null; } if (str_starts_with($track->path, 'users/')) { $this->ensureLocalAsset($regularPath, $track->path); if (file_exists($regularPath)) { Log::info('NAS: audio track cached locally for streaming', [ 'track_id' => $track->id, 'video_id' => $track->video_id, ]); return $regularPath; } } Log::warning('NAS: failed to resolve audio track for streaming', [ 'track_id' => $track->id, 'video_id' => $track->video_id, 'path' => $track->path, ]); return null; } /** * 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 cached NAS video files from nas_cache/videos/. * * @param int $olderThanHours Only delete files last-accessed more than N hours ago. * Pass 0 to delete everything regardless of age. * @return int Number of files deleted. */ public function clearNasCache(int $olderThanHours = 24): int { $cacheDir = storage_path('app/nas_cache/videos'); if (! is_dir($cacheDir)) return 0; $cutoff = time() - ($olderThanHours * 3600); $deleted = 0; foreach (glob("{$cacheDir}/*") as $file) { if (! is_file($file)) continue; // Use mtime (last modified) as a proxy for last-used if ($olderThanHours === 0 || filemtime($file) < $cutoff) { if (@unlink($file)) { $deleted++; Log::info('NAS cache: evicted ' . basename($file)); } } } // Remove the directory itself if now empty if (is_dir($cacheDir) && empty(array_diff(scandir($cacheDir) ?: [], ['.', '..']))) { @rmdir($cacheDir); $parent = dirname($cacheDir); // nas_cache/ if (is_dir($parent) && empty(array_diff(scandir($parent) ?: [], ['.', '..']))) { @rmdir($parent); } } return $deleted; } /** * Return the total size in bytes of all files in nas_cache/videos/. */ public function nasCacheSize(): int { $cacheDir = storage_path('app/nas_cache/videos'); if (! is_dir($cacheDir)) return 0; $total = 0; foreach (glob("{$cacheDir}/*") as $file) { if (is_file($file)) $total += filesize($file); } return $total; } /** * 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]); } /** * Remove the local video directory and all empty ancestor directories up to * storage/app/users. Call this after deleteLocalVideo + deleteLocalAssets so * no ghost folders are left behind. */ public function pruneLocalVideoDir(Video $video): void { $videoDir = $this->localVideoDir($video); // Remove meta.json helper file @unlink("{$videoDir}/meta.json"); // Remove slides/ subdirectory if empty $slidesDir = "{$videoDir}/slides"; if (is_dir($slidesDir) && $this->isDirEmpty($slidesDir)) { @rmdir($slidesDir); } // Remove the video directory itself if now empty if (is_dir($videoDir) && $this->isDirEmpty($videoDir)) { @rmdir($videoDir); // Walk up: remove videos/ and users/{username}/ if they become empty $parent = dirname($videoDir); // …/users/{username}/videos if (is_dir($parent) && $this->isDirEmpty($parent)) { @rmdir($parent); $grandparent = dirname($parent); // …/users/{username} if (is_dir($grandparent) && $this->isDirEmpty($grandparent)) { @rmdir($grandparent); } } } Log::info('NAS: local video directory pruned', ['video_id' => $video->id, 'dir' => $videoDir]); } private function isDirEmpty(string $dir): bool { $items = array_diff(scandir($dir) ?: [], ['.', '..']); return empty($items); } // ── 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)) { $ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}"); if (! $ok) { // Leave the local temp file intact so the caller can fall back to local storage throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$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}"; if ($this->putFile($absPath, $nasSlideFile)) { @unlink($absPath); $slideRelPath = $nasSlideFile; $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) { if ($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', 'slides']); $dir = $this->resolveVideoDir($video); $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; // ── Files in the video root ─────────────────────────────────────────── $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"); // ── slides/ subdirectory ────────────────────────────────────────────── // Delete each known slide file, then any leftover wildcard, then the dir. foreach ($video->slides as $slide) { $slideExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg'; $this->deleteFile("{$dir}/slides/{$slide->position}.{$slideExt}"); } $this->deleteFilesInDir("{$dir}/slides"); // catch anything not in DB $this->deleteFolder("{$dir}/slides"); // ── Video directory itself ──────────────────────────────────────────── $this->deleteFolder($dir); // ── Local NAS stream-cache copy ─────────────────────────────────────── $cachePath = $this->localCachePath($video); if (file_exists($cachePath)) @unlink($cachePath); Log::info('NAS: video deleted', ['video_id' => $video->id, 'dir' => $dir]); } // ── Posts ───────────────────────────────────────────────────────────────── /** * NAS directory for a post's attachments: users/{slug}/posts/{id}/ */ public function resolvePostDir(\App\Models\Post $post): string { $post->loadMissing('user'); return 'users/' . $this->userSlug($post->user) . '/posts/' . $post->id; } /** * Upload all new-format post images to NAS (NAS path == relative path stored in DB). */ public function syncPostImages(\App\Models\Post $post): void { $post->loadMissing('postImages'); $dir = $this->resolvePostDir($post); $this->mkdirp($dir); if ($post->image && str_starts_with($post->image, 'users/')) { $local = storage_path('app/' . $post->image); if (file_exists($local)) $this->putFile($local, $post->image); } foreach ($post->postImages as $img) { if (! str_starts_with($img->filename, 'users/')) continue; $local = storage_path('app/' . $img->filename); if (file_exists($local)) $this->putFile($local, $img->filename); } } /** * Delete local post image files and prune the empty local post directory. * Handles both the old flat format and the new users/{slug}/posts/{id}/ format. */ public function deleteLocalPostImages(\App\Models\Post $post): void { $post->loadMissing('postImages'); $paths = []; if ($post->image) { $paths[] = str_starts_with($post->image, 'users/') ? storage_path('app/' . $post->image) : storage_path('app/public/post_images/' . $post->image); } foreach ($post->postImages as $img) { $paths[] = str_starts_with($img->filename, 'users/') ? storage_path('app/' . $img->filename) : storage_path('app/public/post_images/' . $img->filename); } foreach ($paths as $path) { if (file_exists($path)) @unlink($path); } // Prune empty local post dir $localDir = storage_path('app/' . $this->resolvePostDir($post)); if (is_dir($localDir) && empty(array_diff(scandir($localDir) ?: [], ['.', '..']))) { @rmdir($localDir); } } /** * Delete the post's entire NAS directory tree. */ public function deleteNasPost(\App\Models\Post $post): void { $dir = $this->resolvePostDir($post); $this->deleteNasTree($dir); Log::info('NAS: post deleted', ['post_id' => $post->id, 'dir' => $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"); } /** * Absolute path of the local profile directory for a user. * Mirrors the NAS path: storage/app/users/{slug}/profile/ */ public function localProfileDir(User $user): string { return storage_path('app/users/' . $this->userSlug($user) . '/profile'); } public function deleteLocalAvatar(User $user): void { if (! $user->avatar) return; if (str_starts_with($user->avatar, 'users/')) { // New format: relative path stored in DB $path = storage_path('app/' . $user->avatar); } else { // Legacy flat format $path = storage_path('app/public/avatars/' . $user->avatar); } if (file_exists($path)) @unlink($path); } public function deleteLocalBanner(User $user): void { if (! $user->banner) return; if (str_starts_with($user->banner, 'users/')) { $path = storage_path('app/' . $user->banner); } else { $path = storage_path('app/public/banners/' . $user->banner); } if (file_exists($path)) @unlink($path); } /** * Check whether a file exists on the NAS share. * Uses a lightweight smbclient ls — does not download the file. */ public function nasFileExists(string $nasRelPath): bool { $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); $cmd = 'ls "' . $nasRelPath . '"'; exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code); // smbclient exits 0 and output contains the filename when found; // exits non-zero or contains NT_STATUS_NO_SUCH_FILE when missing. if ($code !== 0) return false; foreach ($output as $line) { if (str_contains($line, 'NT_STATUS_NO_SUCH_FILE') || str_contains($line, 'NT_STATUS_OBJECT_NAME_NOT_FOUND')) { return false; } } return true; } // ── 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'); } /** * Delete all files inside a NAS directory using a wildcard. * Does NOT remove the directory itself — call deleteFolder() after. */ public function deleteFilesInDir(string $nasRelPath): void { $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); // smbclient `del` accepts a mask relative to the share root exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('del "' . $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'); } /** * Rename the video's NAS and local directories to match the current title, * then update all affected DB paths (video, slides). * * Call this after video.title has been saved but before syncVideo(), so that * the sync job writes to the correctly-named folder. */ public function renameVideoDir(Video $video): void { if (! str_starts_with($video->path, 'users/')) return; $video->loadMissing(['user', 'slides', 'audioTracks']); $userSlug = $this->userSlug($video->user); $base = "users/{$userSlug}/videos"; // Current folder derived from the stored path (title not yet reflected in folder name) $currentDir = dirname($video->path); // "users/{slug}/videos/{old-slug}" // If the primary file lives inside a 'tracks/' subfolder (promoted track), // go up one extra level to reach the video root directory. if (basename($currentDir) === 'tracks') { $currentDir = dirname($currentDir); } // Desired folder based on the (already-saved) new title $newDir = $this->findFreeVideoDir($base, $this->titleSlug($video->title), $video->id); if ($currentDir === $newDir) return; // Rename on NAS $this->renameNasPath($currentDir, $newDir); // Rename locally if the dir exists $oldLocal = storage_path('app/' . $currentDir); $newLocal = storage_path('app/' . $newDir); if (is_dir($oldLocal)) { rename($oldLocal, $newLocal); } // Update all DB paths that reference the old directory $oldPrefix = $currentDir . '/'; $newPrefix = $newDir . '/'; $videoUpdates = []; if (str_starts_with($video->path, $oldPrefix)) { $videoUpdates['path'] = $newPrefix . substr($video->path, strlen($oldPrefix)); } if ($video->thumbnail && str_starts_with($video->thumbnail, $oldPrefix)) { $videoUpdates['thumbnail'] = $newPrefix . substr($video->thumbnail, strlen($oldPrefix)); } if (! empty($videoUpdates)) { $video->update($videoUpdates); $video->refresh(); } foreach ($video->slides as $slide) { if (str_starts_with($slide->filename, $oldPrefix)) { $slide->update(['filename' => $newPrefix . substr($slide->filename, strlen($oldPrefix))]); } } // Secondary language tracks (incl. the demoted old primary after a swap) also // live under the renamed directory — update their stored paths too. foreach ($video->audioTracks as $track) { if ($track->path && str_starts_with($track->path, $oldPrefix)) { $track->update(['path' => $newPrefix . substr($track->path, strlen($oldPrefix))]); } } Log::info('NAS: video dir renamed', [ 'video_id' => $video->id, 'from' => $currentDir, 'to' => $newDir, ]); } /** * Find a free (or already-owned) NAS video directory for the given slug. */ private function findFreeVideoDir(string $base, string $slug, int $videoId): string { for ($i = 1; $i <= 50; $i++) { $candidate = $i === 1 ? $slug : "{$slug}-{$i}"; $meta = $this->readMeta("{$base}/{$candidate}"); if ($meta === null) return "{$base}/{$candidate}"; if (($meta['id'] ?? null) === $videoId) return "{$base}/{$candidate}"; } return "{$base}/{$slug}-{$videoId}"; } /** * Rename a path on the NAS share (works for both files and directories). */ private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void { $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); $old = str_replace('/', '\\', $oldNasRelPath); $new = str_replace('/', '\\', $newNasRelPath); exec( 'smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("rename \"{$old}\" \"{$new}\"") . ' 2>&1', $output, $code ); if ($code !== 0) { Log::warning('NAS rename failed', [ 'old' => $oldNasRelPath, 'new' => $newNasRelPath, 'output' => implode(' ', $output), ]); } } /** * List subdirectory names directly under a NAS path. */ public function listNasDirs(string $nasRelPath): array { $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); $smbPath = str_replace('/', '\\', $nasRelPath) . '\\*'; exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output); $dirs = []; foreach ($output as $line) { if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue; $name = trim($m[1]); if ($name === '.' || $name === '..') continue; $dirs[] = $name; } return $dirs; } /** * Recursively delete a NAS directory tree (all files, subdirs, then the dir itself). */ public function deleteNasTree(string $nasRelPath): void { foreach ($this->listNasDirs($nasRelPath) as $subdir) { $this->deleteNasTree("{$nasRelPath}/{$subdir}"); } $this->deleteFilesInDir($nasRelPath); $this->deleteFolder($nasRelPath); } /** * Scan every video folder on the NAS and return those whose meta.json * references a video ID that no longer exists in the database. * * Returns array of ['dir' => 'users/{slug}/videos/{slug}', 'video_id' => int|null] */ public function scanNasOrphans(): array { $orphans = []; $userDirs = $this->listNasDirs('users'); foreach ($userDirs as $userSlug) { $videosBase = "users/{$userSlug}/videos"; $videoDirs = $this->listNasDirs($videosBase); foreach ($videoDirs as $videoSlug) { $dir = "{$videosBase}/{$videoSlug}"; $meta = $this->readMeta($dir); $videoId = $meta ? ($meta['id'] ?? null) : null; if ($videoId === null) { $orphans[] = ['dir' => $dir, 'video_id' => null]; continue; } if (! \App\Models\Video::where('id', $videoId)->exists()) { $orphans[] = ['dir' => $dir, 'video_id' => $videoId]; } } } return $orphans; } // ── 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'] ?? ''); } }