Make NAS the primary storage when enabled (not a mirror)

When NAS sync is enabled:
- Audio uploads: pushed to NAS via NasSyncVideoJob, local file deleted immediately after
- Video uploads: processed locally (ffprobe, compress, HLS), then at the end of
  GenerateHlsJob the final compressed file is re-synced to NAS and the local copy removed
- stream() and download(): if local file is missing, pull from NAS into a local
  stream cache (storage/app/nas_cache/videos/) and serve from there with full
  byte-range support — so seeking still works over NAS-sourced files

When NAS is disabled:
- Upload, stream, and download all use local storage exclusively (no change)

HLS segments are intentionally kept local: they are small, generated on-demand,
and serving them via per-segment SMB round-trips would hurt playback performance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-14 01:56:55 +03:00
parent d1441b213a
commit 0b75acec89
4 changed files with 460 additions and 2 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\CompressVideoJob; use App\Jobs\CompressVideoJob;
use App\Jobs\NasSyncVideoJob;
use App\Mail\NewVideoNotification; use App\Mail\NewVideoNotification;
use App\Mail\VideoUploaded; use App\Mail\VideoUploaded;
use App\Notifications\NewVideoUploaded as NewVideoUploadedNotification; use App\Notifications\NewVideoUploaded as NewVideoUploadedNotification;
@ -259,6 +260,12 @@ class VideoController extends Controller
->onConnection('database'); ->onConnection('database');
} }
try {
NasSyncVideoJob::dispatch($video);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
}
$video->load('user'); $video->load('user');
$userEmail = Auth::user()->email; $userEmail = Auth::user()->email;
@ -542,6 +549,12 @@ class VideoController extends Controller
$video->update($data); $video->update($data);
try {
NasSyncVideoJob::dispatch($video->fresh());
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
}
AuditLog::record('video.updated', [ AuditLog::record('video.updated', [
'subject_type' => 'Video', 'subject_type' => 'Video',
'subject_id' => (string) $video->id, 'subject_id' => (string) $video->id,
@ -592,6 +605,12 @@ class VideoController extends Controller
if ($video->thumbnail) { if ($video->thumbnail) {
Storage::delete('public/thumbnails/'.$video->thumbnail); Storage::delete('public/thumbnails/'.$video->thumbnail);
} }
$nasSync = app(\App\Services\NasSyncService::class);
if ($nasSync->isEnabled()) {
$nasSync->deleteVideo($video);
}
$video->delete(); $video->delete();
if ($request->expectsJson() || $request->ajax()) { if ($request->expectsJson() || $request->ajax()) {
@ -677,9 +696,14 @@ class VideoController extends Controller
$path = storage_path('app/public/videos/'.$video->filename); $path = storage_path('app/public/videos/'.$video->filename);
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
if (! file_exists($path)) { if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found'); abort(404, 'Video file not found');
} }
}
$fileSize = filesize($path); $fileSize = filesize($path);
$mimeType = $video->mime_type ?: 'video/mp4'; $mimeType = $video->mime_type ?: 'video/mp4';
@ -800,9 +824,14 @@ class VideoController extends Controller
$path = storage_path('app/public/videos/' . $video->filename); $path = storage_path('app/public/videos/' . $video->filename);
// If not on local disk, try to pull from NAS
if (! file_exists($path)) { if (! file_exists($path)) {
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.'); abort(404, 'Video file not found.');
} }
}
// Already a video — serve directly, no conversion needed // Already a video — serve directly, no conversion needed
if (! $this->isAudioOnlyFile($video)) { if (! $this->isAudioOnlyFile($video)) {

View File

@ -149,6 +149,16 @@ class GenerateHlsJob implements ShouldQueue
'encoder' => $encoder, 'encoder' => $encoder,
]); ]);
// NAS-primary storage: push the final compressed file to NAS (overwriting
// the uncompressed copy synced at upload time), then free local disk.
// HLS segments are kept local — they're small and serving them locally
// avoids per-segment SMB latency during HLS playback.
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]); Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
Storage::deleteDirectory($hlsDir); Storage::deleteDirectory($hlsDir);

View File

@ -0,0 +1,38 @@
<?php
namespace App\Jobs;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NasSyncVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly Video $video) {}
public function handle(NasSyncService $nas): void
{
if (! $nas->isEnabled()) return;
try {
$nas->syncVideo($this->video);
// Audio/music uploads have no further processing jobs, so it's safe
// to remove the local file immediately after a successful NAS push.
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
}
}
}

View File

@ -0,0 +1,381 @@
<?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
{
$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'] ?? '');
}
}