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