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 <noreply@anthropic.com>
This commit is contained in:
parent
296d605864
commit
6b3ab5b65e
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use App\Services\NasSyncService;
|
use App\Services\NasSyncService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
@ -13,7 +14,7 @@ class NasFreeLocalStorage extends Command
|
|||||||
{--dry-run : Preview what would be deleted without deleting}
|
{--dry-run : Preview what would be deleted without deleting}
|
||||||
{--force : Actually delete local files confirmed on NAS}';
|
{--force : Actually delete local files confirmed on NAS}';
|
||||||
|
|
||||||
protected $description = 'Delete local video files that are already stored on the NAS';
|
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||||
|
|
||||||
public function handle(NasSyncService $nas): int
|
public function handle(NasSyncService $nas): int
|
||||||
{
|
{
|
||||||
@ -34,64 +35,188 @@ class NasFreeLocalStorage extends Command
|
|||||||
$this->info($mode);
|
$this->info($mode);
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
// Only videos that still have a local file
|
$totalBytes = 0;
|
||||||
$videos = Video::all()->filter(function (Video $v) {
|
$toDelete = [];
|
||||||
return file_exists(storage_path('app/' . $v->path));
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($videos->isEmpty()) {
|
// ── Videos ───────────────────────────────────────────────────────────
|
||||||
$this->info('No local video files found — nothing to do.');
|
$this->info('Scanning video files…');
|
||||||
return 0;
|
|
||||||
|
$videos = Video::all()->filter(fn (Video $v) => file_exists(storage_path('app/' . $v->path)));
|
||||||
|
|
||||||
|
if ($videos->isNotEmpty()) {
|
||||||
|
$bar = $this->output->createProgressBar($videos->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($videos as $video) {
|
||||||
|
$localPath = storage_path('app/' . $video->path);
|
||||||
|
$dir = $nas->resolveVideoDir($video);
|
||||||
|
$meta = null;
|
||||||
|
try {
|
||||||
|
$raw = $nas->getContent("{$dir}/meta.json");
|
||||||
|
$meta = $raw ? json_decode($raw, true) : null;
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
|
||||||
|
$bytes = filesize($localPath);
|
||||||
|
$totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
} else {
|
||||||
|
$this->line(' No local video files found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Checking {$videos->count()} local file(s) against NAS…");
|
// ── Thumbnails & slides (both legacy flat dir and new NAS-mirrored dirs) ──
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Scanning thumbnail and slide files…');
|
||||||
|
|
||||||
|
// Helper: check if a video's NAS dir is confirmed, using meta.json
|
||||||
|
$confirmNas = function (Video $v) use ($nas): bool {
|
||||||
|
try {
|
||||||
|
$raw = $nas->getContent($nas->resolveVideoDir($v) . '/meta.json');
|
||||||
|
$meta = $raw ? json_decode($raw, true) : null;
|
||||||
|
return is_array($meta) && ($meta['id'] ?? null) === $v->id;
|
||||||
|
} catch (\Throwable) { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy flat thumbnails dir
|
||||||
|
$thumbDir = storage_path('app/public/thumbnails');
|
||||||
|
if (is_dir($thumbDir)) {
|
||||||
|
foreach (glob($thumbDir . '/*') as $file) {
|
||||||
|
if (! is_file($file)) continue;
|
||||||
|
$filename = basename($file);
|
||||||
|
$video = Video::where('thumbnail', $filename)->first();
|
||||||
|
if ($video) {
|
||||||
|
if ($confirmNas($video)) {
|
||||||
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => "thumb:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
|
||||||
|
if ($slide && $slide->video && $confirmNas($slide->video)) {
|
||||||
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => "slide:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New NAS-mirrored dirs: storage/app/users/{username}/videos/{slug}/thumb.*
|
||||||
|
// slides/{id}.*
|
||||||
|
$usersBase = storage_path('app/users');
|
||||||
|
if (is_dir($usersBase)) {
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($usersBase, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile()) continue;
|
||||||
|
$absPath = $file->getPathname();
|
||||||
|
$relPath = ltrim(str_replace(storage_path('app'), '', $absPath), '/');
|
||||||
|
|
||||||
|
// Match thumbnail or slide by relative path stored in DB
|
||||||
|
$video = Video::where('thumbnail', $relPath)->first();
|
||||||
|
if ($video) {
|
||||||
|
if ($confirmNas($video)) {
|
||||||
|
$bytes = $file->getSize(); $totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => 'thumb:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slide = \App\Models\VideoSlide::where('filename', $relPath)->with('video')->first();
|
||||||
|
if ($slide && $slide->video && $confirmNas($slide->video)) {
|
||||||
|
$bytes = $file->getSize(); $totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => 'slide:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(' Done scanning thumbnails.');
|
||||||
|
|
||||||
|
// ── Avatars ───────────────────────────────────────────────────────────
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Scanning avatar files…');
|
||||||
|
|
||||||
|
$avatarDir = storage_path('app/public/avatars');
|
||||||
|
if (is_dir($avatarDir)) {
|
||||||
|
foreach (glob($avatarDir . '/*') as $file) {
|
||||||
|
if (! is_file($file)) continue;
|
||||||
|
$filename = basename($file);
|
||||||
|
$user = User::where('avatar', $filename)->first();
|
||||||
|
if (! $user) continue;
|
||||||
|
|
||||||
|
// Confirm avatar.webp is on NAS
|
||||||
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
|
$raw = null;
|
||||||
|
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||||
|
if ($raw !== null) {
|
||||||
|
$bytes = filesize($file);
|
||||||
|
$totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->line(' Done scanning avatars.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banners ───────────────────────────────────────────────────────────
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Scanning banner files…');
|
||||||
|
|
||||||
|
$bannerDir = storage_path('app/public/banners');
|
||||||
|
if (is_dir($bannerDir)) {
|
||||||
|
foreach (glob($bannerDir . '/*') as $file) {
|
||||||
|
if (! is_file($file)) continue;
|
||||||
|
$filename = basename($file);
|
||||||
|
$user = User::where('banner', $filename)->first();
|
||||||
|
if (! $user) continue;
|
||||||
|
|
||||||
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
|
$raw = null;
|
||||||
|
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||||
|
if ($raw !== null) {
|
||||||
|
$bytes = filesize($file);
|
||||||
|
$totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->line(' Done scanning banners.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slideshow cache directories ───────────────────────────────────────
|
||||||
|
// The slideshow/ directory is a render cache that is always regenerated on
|
||||||
|
// demand, so its contents are safe to delete unconditionally.
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Scanning slideshow cache…');
|
||||||
|
|
||||||
|
$slideshowDir = storage_path('app/public/slideshow');
|
||||||
|
if (is_dir($slideshowDir)) {
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile()) continue;
|
||||||
|
$bytes = $file->getSize();
|
||||||
|
$totalBytes += $bytes;
|
||||||
|
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
$this->line(' Done scanning slideshow cache.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary ───────────────────────────────────────────────────────────
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
$toDelete = [];
|
|
||||||
$totalBytes = 0;
|
|
||||||
$bar = $this->output->createProgressBar($videos->count());
|
|
||||||
$bar->start();
|
|
||||||
|
|
||||||
foreach ($videos as $video) {
|
|
||||||
$localPath = storage_path('app/' . $video->path);
|
|
||||||
|
|
||||||
// meta.json is written last in syncVideo(), so its presence means
|
|
||||||
// the video file was fully pushed to NAS.
|
|
||||||
$dir = $nas->resolveVideoDir($video);
|
|
||||||
$meta = null;
|
|
||||||
try {
|
|
||||||
$raw = $nas->getContent("{$dir}/meta.json");
|
|
||||||
$meta = $raw ? json_decode($raw, true) : null;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// SMB error — skip this file
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
|
|
||||||
$bytes = filesize($localPath);
|
|
||||||
$totalBytes += $bytes;
|
|
||||||
$toDelete[] = [
|
|
||||||
'video' => $video,
|
|
||||||
'path' => $localPath,
|
|
||||||
'bytes' => $bytes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$bar->advance();
|
|
||||||
}
|
|
||||||
|
|
||||||
$bar->finish();
|
|
||||||
$this->newLine(2);
|
|
||||||
|
|
||||||
if (empty($toDelete)) {
|
if (empty($toDelete)) {
|
||||||
$this->info('No local files confirmed on NAS — nothing to delete.');
|
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->table(
|
$this->table(
|
||||||
['ID', 'Title', 'Local file', 'Size'],
|
['Type', 'File', 'Size'],
|
||||||
array_map(fn ($row) => [
|
array_map(fn ($row) => [
|
||||||
$row['video']->id,
|
$row['label'],
|
||||||
\Illuminate\Support\Str::limit($row['video']->title, 40),
|
|
||||||
basename($row['path']),
|
basename($row['path']),
|
||||||
$this->humanBytes($row['bytes']),
|
$this->humanBytes($row['bytes']),
|
||||||
], $toDelete)
|
], $toDelete)
|
||||||
@ -117,7 +242,7 @@ class NasFreeLocalStorage extends Command
|
|||||||
foreach ($toDelete as $row) {
|
foreach ($toDelete as $row) {
|
||||||
if (@unlink($row['path'])) {
|
if (@unlink($row['path'])) {
|
||||||
$deleted++;
|
$deleted++;
|
||||||
Log::info('nas:free-local: deleted ' . $row['path'], ['video_id' => $row['video']->id]);
|
Log::info('nas:free-local: deleted ' . $row['path']);
|
||||||
} else {
|
} else {
|
||||||
$failed++;
|
$failed++;
|
||||||
$this->warn("Could not delete: {$row['path']}");
|
$this->warn("Could not delete: {$row['path']}");
|
||||||
|
|||||||
140
app/Http/Controllers/MediaController.php
Normal file
140
app/Http/Controllers/MediaController.php
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Models\VideoSlide;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
|
||||||
|
* NAS fallback: if the file is missing locally it is fetched from the NAS and
|
||||||
|
* cached in the standard public storage directory before being served.
|
||||||
|
*
|
||||||
|
* All routes are public (no auth) so images render in emails and for guests.
|
||||||
|
*/
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
public function thumbnail(string $filename, NasSyncService $nas): Response
|
||||||
|
{
|
||||||
|
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
|
||||||
|
if (str_starts_with($filename, 'users/')) {
|
||||||
|
$local = storage_path('app/' . $filename);
|
||||||
|
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
@mkdir(dirname($local), 0755, true);
|
||||||
|
|
||||||
|
// The thumbnail field stores the local relative path.
|
||||||
|
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
|
||||||
|
$video = Video::where('thumbnail', $filename)->first();
|
||||||
|
if ($video) {
|
||||||
|
$dir = $nas->resolveVideoDir($video);
|
||||||
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
|
||||||
|
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Might be a slide
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||||
|
if ($slide && $slide->video) {
|
||||||
|
$dir = $nas->resolveVideoDir($slide->video);
|
||||||
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! file_exists($local)) abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileResponse($local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy flat filename format
|
||||||
|
$local = storage_path('app/public/thumbnails/' . $filename);
|
||||||
|
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
// Find the video that owns this thumbnail and pull from NAS
|
||||||
|
$video = Video::where('thumbnail', $filename)->first();
|
||||||
|
|
||||||
|
if ($video) {
|
||||||
|
$dir = $nas->resolveVideoDir($video);
|
||||||
|
$nasPath = "{$dir}/thumb.webp";
|
||||||
|
$nas->ensureLocalAsset($local, $nasPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found via video — might be a slide
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||||
|
if ($slide && $slide->video) {
|
||||||
|
$dir = $nas->resolveVideoDir($slide->video);
|
||||||
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
|
||||||
|
$nas->ensureLocalAsset($local, $nasPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileResponse($local);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avatar(string $filename, NasSyncService $nas): Response
|
||||||
|
{
|
||||||
|
$local = storage_path('app/public/avatars/' . $filename);
|
||||||
|
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$user = User::where('avatar', $filename)->first();
|
||||||
|
if ($user) {
|
||||||
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
|
$nasPath = "{$dir}/avatar.webp";
|
||||||
|
$nas->ensureLocalAsset($local, $nasPath);
|
||||||
|
}
|
||||||
|
if (! file_exists($local)) abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileResponse($local);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banner(string $filename, NasSyncService $nas): Response
|
||||||
|
{
|
||||||
|
$local = storage_path('app/public/banners/' . $filename);
|
||||||
|
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$user = User::where('banner', $filename)->first();
|
||||||
|
if ($user) {
|
||||||
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
|
$nasPath = "{$dir}/cover.webp";
|
||||||
|
$nas->ensureLocalAsset($local, $nasPath);
|
||||||
|
}
|
||||||
|
if (! file_exists($local)) abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileResponse($local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function fileResponse(string $path): Response
|
||||||
|
{
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mime = match ($ext) {
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
default => 'image/jpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return response(file_get_contents($path), 200, [
|
||||||
|
'Content-Type' => $mime,
|
||||||
|
'Cache-Control' => 'public, max-age=86400',
|
||||||
|
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -330,9 +330,9 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
// Delete user's videos and associated files
|
// Delete user's videos and associated files
|
||||||
foreach ($user->videos as $video) {
|
foreach ($user->videos as $video) {
|
||||||
Storage::delete('public/videos/' . $video->filename);
|
Storage::delete($video->path);
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
Storage::delete($video->thumbnailStorageKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$user->videos()->delete();
|
$user->videos()->delete();
|
||||||
@ -550,9 +550,9 @@ class SuperAdminController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Delete files
|
// Delete files
|
||||||
Storage::delete('public/videos/' . $video->filename);
|
Storage::delete($video->path);
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
Storage::delete($video->thumbnailStorageKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete likes and views - use direct queries since relationships have timestamp issues
|
// Delete likes and views - use direct queries since relationships have timestamp issues
|
||||||
@ -795,12 +795,13 @@ class SuperAdminController extends Controller
|
|||||||
public function settings()
|
public function settings()
|
||||||
{
|
{
|
||||||
$settings = [
|
$settings = [
|
||||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||||
|
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$gpus = $this->probeGpus();
|
$gpus = $this->probeGpus();
|
||||||
@ -812,12 +813,13 @@ class SuperAdminController extends Controller
|
|||||||
public function updateSettings(Request $request)
|
public function updateSettings(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'gpu_enabled' => 'required|in:true,false',
|
'gpu_enabled' => 'required|in:true,false',
|
||||||
'gpu_device' => 'required|integer|min:0|max:15',
|
'gpu_device' => 'required|integer|min:0|max:15',
|
||||||
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
||||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||||
'ffmpeg_binary' => 'required|string|max:255',
|
'ffmpeg_binary' => 'required|string|max:255',
|
||||||
|
'nas_sync_enabled' => 'required|in:true,false',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||||
@ -825,12 +827,13 @@ class SuperAdminController extends Controller
|
|||||||
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||||
Setting::set('gpu_preset', $request->gpu_preset);
|
Setting::set('gpu_preset', $request->gpu_preset);
|
||||||
Setting::set('ffmpeg_binary', $binary);
|
Setting::set('ffmpeg_binary', $binary);
|
||||||
|
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
||||||
|
|
||||||
return back()->with('success', 'Settings saved.');
|
return back()->with('success', 'Settings saved.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -254,16 +254,56 @@ class VideoController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $isAudioUpload) {
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
CompressVideoJob::dispatch($video)
|
|
||||||
->onQueue('video-processing')
|
|
||||||
->onConnection('database');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if ($nas->isEnabled()) {
|
||||||
NasSyncVideoJob::dispatch($video);
|
// ── NAS-primary: push directly to NAS, delete local temp files ──
|
||||||
} catch (\Throwable $e) {
|
try {
|
||||||
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
|
$video->load('slides');
|
||||||
|
|
||||||
|
// Build slide abs-paths map for audio uploads
|
||||||
|
$slideAbsPaths = [];
|
||||||
|
foreach ($slideFiles as $pos => $fname) {
|
||||||
|
$slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
|
||||||
|
// For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
|
||||||
|
if ($isAudioUpload) $tempThumbAbs = null;
|
||||||
|
|
||||||
|
$nas->uploadDirectToNas(
|
||||||
|
$video,
|
||||||
|
storage_path('app/' . $path),
|
||||||
|
$tempThumbAbs,
|
||||||
|
$slideAbsPaths
|
||||||
|
);
|
||||||
|
$video->refresh();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('uploadDirectToNas failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
|
||||||
|
if (! $isAudioUpload) {
|
||||||
|
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Local storage: move into NAS-mirrored local directory schema ──
|
||||||
|
try {
|
||||||
|
$video->load('slides');
|
||||||
|
$nas->organizeLocalFiles($video);
|
||||||
|
$video->refresh();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('organizeLocalFiles failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress + HLS pipeline for local storage
|
||||||
|
if (! $isAudioUpload) {
|
||||||
|
CompressVideoJob::dispatch($video)
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$video->load('user');
|
$video->load('user');
|
||||||
@ -450,7 +490,7 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
||||||
'id' => $s->id,
|
'id' => $s->id,
|
||||||
'url' => asset('storage/thumbnails/' . $s->filename),
|
'url' => $s->url,
|
||||||
])->values();
|
])->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -460,7 +500,7 @@ class VideoController extends Controller
|
|||||||
'title' => $video->title,
|
'title' => $video->title,
|
||||||
'description' => $video->description,
|
'description' => $video->description,
|
||||||
'thumbnail' => $video->thumbnail,
|
'thumbnail' => $video->thumbnail,
|
||||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : null,
|
'thumbnail_url' => $video->thumbnail_url,
|
||||||
'visibility' => $video->visibility ?? 'public',
|
'visibility' => $video->visibility ?? 'public',
|
||||||
'type' => $video->type ?? 'generic',
|
'type' => $video->type ?? 'generic',
|
||||||
'download_access' => $video->download_access,
|
'download_access' => $video->download_access,
|
||||||
@ -492,11 +532,23 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
Storage::delete($video->thumbnailStorageKey());
|
||||||
|
}
|
||||||
|
if (str_starts_with($video->path, 'users/')) {
|
||||||
|
// New-format video: store thumbnail in the video's local dir
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
$localDir = $nas->localVideoDir($video);
|
||||||
|
@mkdir($localDir, 0755, true);
|
||||||
|
$ext = $request->file('thumbnail')->getClientOriginalExtension();
|
||||||
|
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
|
||||||
|
$userSlug = $nas->userSlug($video->user);
|
||||||
|
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
||||||
|
} else {
|
||||||
|
// Legacy video: keep in flat thumbnails dir
|
||||||
|
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||||||
|
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||||
|
$data['thumbnail'] = basename($data['thumbnail']);
|
||||||
}
|
}
|
||||||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
|
||||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
|
||||||
$data['thumbnail'] = basename($data['thumbnail']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! isset($data['visibility'])) {
|
if (! isset($data['visibility'])) {
|
||||||
@ -510,8 +562,8 @@ class VideoController extends Controller
|
|||||||
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
||||||
|
|
||||||
// Delete slides not in the kept list
|
// Delete slides not in the kept list
|
||||||
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) {
|
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
|
||||||
Storage::delete('public/thumbnails/' . $slide->filename);
|
Storage::delete($slide->storageKey());
|
||||||
$slide->delete();
|
$slide->delete();
|
||||||
$slidesChanged = true;
|
$slidesChanged = true;
|
||||||
});
|
});
|
||||||
@ -526,10 +578,27 @@ class VideoController extends Controller
|
|||||||
// Add new slides
|
// Add new slides
|
||||||
if ($request->hasFile('slides_add')) {
|
if ($request->hasFile('slides_add')) {
|
||||||
$nextPos = count($keptOrder);
|
$nextPos = count($keptOrder);
|
||||||
|
$isNewFormat = str_starts_with($video->path, 'users/');
|
||||||
|
if ($isNewFormat) {
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
$localDir = $nas->localVideoDir($video);
|
||||||
|
@mkdir("{$localDir}/slides", 0755, true);
|
||||||
|
$userSlug = $nas->userSlug($video->user);
|
||||||
|
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
||||||
|
}
|
||||||
foreach ($request->file('slides_add') as $file) {
|
foreach ($request->file('slides_add') as $file) {
|
||||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
if ($isNewFormat) {
|
||||||
$file->storeAs('public/thumbnails', $fname);
|
// Create placeholder record to get the auto-increment ID
|
||||||
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos++]);
|
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||||||
|
$ext = $file->getClientOriginalExtension();
|
||||||
|
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
||||||
|
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
||||||
|
} else {
|
||||||
|
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||||
|
$file->storeAs('public/thumbnails', $fname);
|
||||||
|
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos]);
|
||||||
|
}
|
||||||
|
$nextPos++;
|
||||||
$slidesChanged = true;
|
$slidesChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -601,9 +670,9 @@ class VideoController extends Controller
|
|||||||
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
|
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Storage::delete('public/videos/'.$video->filename);
|
Storage::delete($video->path);
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
Storage::delete($video->thumbnailStorageKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
$nasSync = app(\App\Services\NasSyncService::class);
|
$nasSync = app(\App\Services\NasSyncService::class);
|
||||||
@ -694,7 +763,7 @@ class VideoController extends Controller
|
|||||||
abort(404, 'Video not found');
|
abort(404, 'Video not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = storage_path('app/public/videos/'.$video->filename);
|
$path = $video->localVideoPath();
|
||||||
|
|
||||||
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
|
// If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
|
||||||
if (! file_exists($path)) {
|
if (! file_exists($path)) {
|
||||||
@ -822,7 +891,7 @@ class VideoController extends Controller
|
|||||||
$this->checkDownloadAccess($video);
|
$this->checkDownloadAccess($video);
|
||||||
$this->recordDownload($video, 'video');
|
$this->recordDownload($video, 'video');
|
||||||
|
|
||||||
$path = storage_path('app/public/videos/' . $video->filename);
|
$path = $video->localVideoPath();
|
||||||
|
|
||||||
// If not on local disk, try to pull from NAS
|
// If not on local disk, try to pull from NAS
|
||||||
if (! file_exists($path)) {
|
if (! file_exists($path)) {
|
||||||
@ -875,7 +944,7 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||||||
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||||
$audioPath = storage_path('app/public/videos/' . $video->filename);
|
$audioPath = $video->localVideoPath();
|
||||||
|
|
||||||
if (! file_exists($audioPath)) {
|
if (! file_exists($audioPath)) {
|
||||||
return response()->json(['error' => 'Audio file not found'], 404);
|
return response()->json(['error' => 'Audio file not found'], 404);
|
||||||
@ -918,9 +987,7 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$slides = $video->slides()->orderBy('position')->get();
|
$slides = $video->slides()->orderBy('position')->get();
|
||||||
$validSlides = $slides->filter(fn($s) => file_exists(
|
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
|
||||||
storage_path('app/public/thumbnails/' . $s->filename)
|
|
||||||
))->values();
|
|
||||||
|
|
||||||
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||||||
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
|
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
|
||||||
@ -941,7 +1008,7 @@ class VideoController extends Controller
|
|||||||
$inputs = '';
|
$inputs = '';
|
||||||
$scaleFc = [];
|
$scaleFc = [];
|
||||||
foreach ($validSlides as $i => $slide) {
|
foreach ($validSlides as $i => $slide) {
|
||||||
$imgPath = storage_path('app/public/thumbnails/' . $slide->filename);
|
$imgPath = $slide->localPath();
|
||||||
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
|
$inputs .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
|
||||||
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
|
$scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
|
||||||
}
|
}
|
||||||
@ -966,7 +1033,7 @@ class VideoController extends Controller
|
|||||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||||
|
|
||||||
} elseif ($validSlides->count() === 1) {
|
} elseif ($validSlides->count() === 1) {
|
||||||
$imgPath = storage_path('app/public/thumbnails/' . $validSlides->first()->filename);
|
$imgPath = $validSlides->first()->localPath();
|
||||||
$cmd = "{$ffmpeg} -y"
|
$cmd = "{$ffmpeg} -y"
|
||||||
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
||||||
. ' -i ' . escapeshellarg($audioPath)
|
. ' -i ' . escapeshellarg($audioPath)
|
||||||
@ -1057,7 +1124,7 @@ class VideoController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkDownloadAccess($video);
|
$this->checkDownloadAccess($video);
|
||||||
|
|
||||||
$path = storage_path('app/public/videos/' . $video->filename);
|
$path = $video->localVideoPath();
|
||||||
|
|
||||||
if (! file_exists($path)) {
|
if (! file_exists($path)) {
|
||||||
abort(404, 'Video file not found.');
|
abort(404, 'Video file not found.');
|
||||||
@ -1274,8 +1341,8 @@ class VideoController extends Controller
|
|||||||
{
|
{
|
||||||
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
|
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
$path = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
$path = $video->localThumbnailPath();
|
||||||
if (file_exists($path)) {
|
if ($path && file_exists($path)) {
|
||||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
// Load source image via GD
|
// Load source image via GD
|
||||||
$src = match($ext) {
|
$src = match($ext) {
|
||||||
|
|||||||
@ -38,9 +38,9 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create compressed filename
|
// Create compressed file alongside the original
|
||||||
$compressedFilename = 'compressed_' . $video->filename;
|
$compressedFilename = 'compressed_' . $video->filename;
|
||||||
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ffmpegConfig = Config::get('ffmpeg');
|
$ffmpegConfig = Config::get('ffmpeg');
|
||||||
|
|||||||
@ -33,10 +33,23 @@ class GenerateHlsJob implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourcePath = storage_path('app/' . $video->path);
|
$sourcePath = storage_path('app/' . $video->path);
|
||||||
if (!file_exists($sourcePath)) {
|
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
|
||||||
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
|
|
||||||
return;
|
if (! file_exists($sourcePath)) {
|
||||||
|
// NAS-primary mode: file lives on NAS, download a temporary local copy
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
$localCopy = $nas->ensureLocalCopy($video);
|
||||||
|
if ($localCopy) {
|
||||||
|
$sourcePath = $localCopy;
|
||||||
|
$nasDownloaded = $localCopy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! file_exists($sourcePath)) {
|
||||||
|
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$hlsDir = 'public/hls/' . $video->id;
|
$hlsDir = 'public/hls/' . $video->id;
|
||||||
@ -149,14 +162,19 @@ 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);
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
if ($nas->isEnabled()) {
|
if ($nas->isEnabled()) {
|
||||||
$nas->syncVideo($video);
|
if ($nasDownloaded) {
|
||||||
$nas->deleteLocalVideo($video);
|
// NAS-primary mode: video was fetched from NAS for HLS generation.
|
||||||
|
// The original is already on NAS — just delete the local temp copy.
|
||||||
|
@unlink($nasDownloaded);
|
||||||
|
} else {
|
||||||
|
// Local-storage mode: push the (compressed) file to NAS and free local disk.
|
||||||
|
// HLS segments stay local — per-segment SMB latency would hurt playback.
|
||||||
|
$nas->syncVideo($video);
|
||||||
|
$nas->deleteLocalVideo($video);
|
||||||
|
$nas->deleteLocalAssets($video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class NasSyncVideoJob implements ShouldQueue
|
|||||||
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
||||||
if ($this->video->type === 'music') {
|
if ($this->video->type === 'music') {
|
||||||
$nas->deleteLocalVideo($this->video);
|
$nas->deleteLocalVideo($this->video);
|
||||||
|
$nas->deleteLocalAssets($this->video);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
|
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
|
||||||
|
|||||||
@ -69,19 +69,53 @@ class Video extends Model
|
|||||||
return $this->slides()->count() > 1;
|
return $this->slides()->count() > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Local filesystem path helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the video file on local disk.
|
||||||
|
* Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…").
|
||||||
|
*/
|
||||||
|
public function localVideoPath(): string
|
||||||
|
{
|
||||||
|
return storage_path('app/' . $this->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the thumbnail on local disk.
|
||||||
|
* Old format: storage/app/public/thumbnails/{filename}
|
||||||
|
* New format: storage/app/{relative-path} (path contains a slash, e.g. users/…/thumb.jpg)
|
||||||
|
*/
|
||||||
|
public function localThumbnailPath(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->thumbnail) return null;
|
||||||
|
return str_contains($this->thumbnail, '/')
|
||||||
|
? storage_path('app/' . $this->thumbnail)
|
||||||
|
: storage_path('app/public/thumbnails/' . $this->thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage::delete()-compatible key for the thumbnail.
|
||||||
|
*/
|
||||||
|
public function thumbnailStorageKey(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->thumbnail) return null;
|
||||||
|
return str_contains($this->thumbnail, '/')
|
||||||
|
? $this->thumbnail
|
||||||
|
: 'public/thumbnails/' . $this->thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
public function getUrlAttribute()
|
public function getUrlAttribute()
|
||||||
{
|
{
|
||||||
return asset('storage/videos/'.$this->filename);
|
return asset('storage/videos/'.$this->filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getThumbnailUrlAttribute()
|
public function getThumbnailUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
if ($this->thumbnail) {
|
if ($this->thumbnail) {
|
||||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
return route('media.thumbnail', $this->thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return null when no thumbnail - social platforms will use their own preview
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,28 @@ class VideoSlide extends Model
|
|||||||
|
|
||||||
public function getUrlAttribute(): string
|
public function getUrlAttribute(): string
|
||||||
{
|
{
|
||||||
return asset('storage/thumbnails/' . $this->filename);
|
return route('media.thumbnail', $this->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the slide image on local disk.
|
||||||
|
* Old format: storage/app/public/thumbnails/{filename}
|
||||||
|
* New format: storage/app/{relative-path} (filename contains a slash)
|
||||||
|
*/
|
||||||
|
public function localPath(): string
|
||||||
|
{
|
||||||
|
return str_contains($this->filename, '/')
|
||||||
|
? storage_path('app/' . $this->filename)
|
||||||
|
: storage_path('app/public/thumbnails/' . $this->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage::delete()-compatible key for this slide file.
|
||||||
|
*/
|
||||||
|
public function storageKey(): string
|
||||||
|
{
|
||||||
|
return str_contains($this->filename, '/')
|
||||||
|
? $this->filename
|
||||||
|
: 'public/thumbnails/' . $this->filename;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,6 +109,138 @@ class NasSyncService
|
|||||||
return null;
|
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) ───────────
|
// ── Local-cache helpers (used when NAS is the primary storage) ───────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -173,6 +305,39 @@ class NasSyncService
|
|||||||
return $cachePath;
|
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.
|
* 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 ───────────────────────────────────────────────
|
// ── High-level sync methods ───────────────────────────────────────────────
|
||||||
|
|
||||||
public function syncVideo(Video $video): void
|
public function syncVideo(Video $video): void
|
||||||
@ -202,10 +471,21 @@ class NasSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// thumb.webp
|
// thumb.webp
|
||||||
if ($video->thumbnail) {
|
$localThumb = $video->localThumbnailPath();
|
||||||
$localThumb = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
if ($localThumb && file_exists($localThumb)) {
|
||||||
if (file_exists($localThumb)) {
|
$this->putFile($localThumb, "{$dir}/thumb.webp");
|
||||||
$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
|
private function cfg(): array
|
||||||
{
|
{
|
||||||
return config('nas-file-manager.connection', []);
|
return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function smbTarget(array $cfg): string
|
private function smbTarget(array $cfg): string
|
||||||
|
|||||||
@ -317,6 +317,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- ── NAS Storage ───────────────────────────────────────────── --}}
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<i class="bi bi-hdd-network"></i>
|
||||||
|
NAS Storage
|
||||||
|
@if($settings['nas_sync_enabled'] === 'true')
|
||||||
|
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
|
||||||
|
@else
|
||||||
|
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
|
||||||
|
<div class="setting-row" style="padding-top:0;">
|
||||||
|
<div class="setting-label">
|
||||||
|
<strong>Use NAS as primary storage</strong>
|
||||||
|
<small>
|
||||||
|
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
||||||
|
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
||||||
|
Thumbnails and video are fetched from NAS on demand for streaming and playback.
|
||||||
|
When disabled, files are stored in local storage using the same directory schema.
|
||||||
|
Requires the NAS connection to be configured under
|
||||||
|
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-wrap">
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
|
||||||
|
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
|
||||||
|
<div class="toggle-track"></div>
|
||||||
|
<div class="toggle-thumb"></div>
|
||||||
|
</div>
|
||||||
|
<span class="toggle-label" id="nasSyncLabel">
|
||||||
|
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
|
||||||
|
value="{{ $settings['nas_sync_enabled'] }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- ── Save ─────────────────────────────────────────────────── --}}
|
{{-- ── Save ─────────────────────────────────────────────────── --}}
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
|
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
|
||||||
@ -347,6 +392,15 @@ function selectEncoder(el) {
|
|||||||
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NAS sync toggle ───────────────────────────────────────────
|
||||||
|
const nasToggle = document.getElementById('nasSyncInput');
|
||||||
|
const nasHidden = document.getElementById('nasSyncHidden');
|
||||||
|
const nasLabel = document.getElementById('nasSyncLabel');
|
||||||
|
nasToggle.addEventListener('change', () => {
|
||||||
|
nasHidden.value = nasToggle.checked ? 'true' : 'false';
|
||||||
|
nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled';
|
||||||
|
});
|
||||||
|
|
||||||
// ── GPU toggle ────────────────────────────────────────────────
|
// ── GPU toggle ────────────────────────────────────────────────
|
||||||
const gpuToggle = document.getElementById('gpuEnabledInput');
|
const gpuToggle = document.getElementById('gpuEnabledInput');
|
||||||
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user