Use NAS as primary storage — direct upload when enabled

When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.

- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
  organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
  deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
  file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
  thumbnailStorageKey(), localPath(), storageKey() helpers for
  format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-14 17:17:07 +03:00
1 parent 296d605864
commit 6b3ab5b65e
11 files changed
+869 -125

No files matched your search

+285 -5
View File
@@ -109,6 +109,138 @@ class NasSyncService
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) ───────────
/**
@@ -173,6 +305,39 @@ class NasSyncService
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.
*/
@@ -185,6 +350,110 @@ class NasSyncService
}
}
/**
* 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
@@ -202,10 +471,21 @@ class NasSyncService
}
// thumb.webp
if ($video->thumbnail) {
$localThumb = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($localThumb)) {
$this->putFile($localThumb, "{$dir}/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}");
}
}
}
@@ -365,7 +645,7 @@ class NasSyncService
private function cfg(): array
{
return config('nas-file-manager.connection', []);
return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
}
private function smbTarget(array $cfg): string