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:
1 parent
296d605864
commit
6b3ab5b65e
11 files changed
+869
-125
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -13,7 +14,7 @@ class NasFreeLocalStorage extends Command
|
||||
{--dry-run : Preview what would be deleted without deleting}
|
||||
{--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
|
||||
{
|
||||
@@ -34,64 +35,188 @@ class NasFreeLocalStorage extends Command
|
||||
$this->info($mode);
|
||||
$this->newLine();
|
||||
|
||||
// Only videos that still have a local file
|
||||
$videos = Video::all()->filter(function (Video $v) {
|
||||
return file_exists(storage_path('app/' . $v->path));
|
||||
});
|
||||
$totalBytes = 0;
|
||||
$toDelete = [];
|
||||
|
||||
if ($videos->isEmpty()) {
|
||||
$this->info('No local video files found — nothing to do.');
|
||||
return 0;
|
||||
// ── Videos ───────────────────────────────────────────────────────────
|
||||
$this->info('Scanning video files…');
|
||||
|
||||
$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();
|
||||
|
||||
$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)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Title', 'Local file', 'Size'],
|
||||
['Type', 'File', 'Size'],
|
||||
array_map(fn ($row) => [
|
||||
$row['video']->id,
|
||||
\Illuminate\Support\Str::limit($row['video']->title, 40),
|
||||
$row['label'],
|
||||
basename($row['path']),
|
||||
$this->humanBytes($row['bytes']),
|
||||
], $toDelete)
|
||||
@@ -117,7 +242,7 @@ class NasFreeLocalStorage extends Command
|
||||
foreach ($toDelete as $row) {
|
||||
if (@unlink($row['path'])) {
|
||||
$deleted++;
|
||||
Log::info('nas:free-local: deleted ' . $row['path'], ['video_id' => $row['video']->id]);
|
||||
Log::info('nas:free-local: deleted ' . $row['path']);
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn("Could not delete: {$row['path']}");
|
||||
|
||||
Reference in new issue
Block a user