takeone-youtube-clone/app/Services/NasSyncService.php
ghassan c160242dbc WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:15:20 +03:00

1108 lines
42 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use Illuminate\Support\Facades\Log;
class NasSyncService
{
// ── Enable check ──────────────────────────────────────────────────────────
public function isEnabled(): bool
{
return Setting::get('nas_sync_enabled', 'false') === 'true'
&& ! empty($this->cfg()['host']);
}
// ── 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/')) {
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 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)) {
$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', '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']);
$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}"
// 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))]);
}
}
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'] ?? '');
}
}