367 lines
16 KiB
PHP
367 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Video;
|
|
use App\Services\NasSyncService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class NasFreeLocalStorage extends Command
|
|
{
|
|
protected $signature = 'nas:free-local
|
|
{--dry-run : Preview what would be deleted without deleting}
|
|
{--force : Actually delete local files confirmed on NAS}
|
|
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
|
|
|
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
|
|
|
public function handle(NasSyncService $nas): int
|
|
{
|
|
if (! $nas->isEnabled()) {
|
|
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
|
|
return 1;
|
|
}
|
|
|
|
$dryRun = $this->option('dry-run');
|
|
$force = $this->option('force');
|
|
|
|
if (! $dryRun && ! $force) {
|
|
$this->warn('Pass --dry-run to preview, or --force to delete.');
|
|
return 1;
|
|
}
|
|
|
|
$mode = $dryRun ? 'DRY RUN — nothing will be deleted' : 'FORCE — deleting confirmed NAS files';
|
|
$this->info($mode);
|
|
$this->newLine();
|
|
|
|
$totalBytes = 0;
|
|
$toDelete = [];
|
|
|
|
// ── 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.');
|
|
}
|
|
|
|
// ── 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…');
|
|
|
|
// Legacy flat dir
|
|
$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;
|
|
|
|
$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];
|
|
}
|
|
}
|
|
}
|
|
|
|
// New structured dir: users/{slug}/profile/avatar.*
|
|
$usersBase = storage_path('app/users');
|
|
if (is_dir($usersBase)) {
|
|
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
|
|
if (! is_file($file)) continue;
|
|
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
|
$user = User::where('avatar', $relPath)->first();
|
|
if (! $user) continue;
|
|
|
|
$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:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->line(' Done scanning avatars.');
|
|
|
|
// ── Banners ───────────────────────────────────────────────────────────
|
|
$this->newLine();
|
|
$this->info('Scanning banner files…');
|
|
|
|
// Legacy flat dir
|
|
$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];
|
|
}
|
|
}
|
|
}
|
|
|
|
// New structured dir: users/{slug}/profile/cover.*
|
|
if (is_dir($usersBase)) {
|
|
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
|
|
if (! is_file($file)) continue;
|
|
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
|
$user = User::where('banner', $relPath)->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:' . basename($file), '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.');
|
|
}
|
|
|
|
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
|
|
// These are on-demand local copies of NAS videos used for HTTP streaming.
|
|
// Always safe to delete — they are re-downloaded from NAS on next play.
|
|
$this->newLine();
|
|
$this->info('Scanning NAS stream cache…');
|
|
|
|
$nasCacheDir = storage_path('app/nas_cache/videos');
|
|
$ttl = (int) $this->option('cache-ttl');
|
|
$cutoff = time() - ($ttl * 3600);
|
|
|
|
if (is_dir($nasCacheDir)) {
|
|
foreach (glob("{$nasCacheDir}/*") as $file) {
|
|
if (! is_file($file)) continue;
|
|
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
|
|
$bytes = filesize($file);
|
|
$totalBytes += $bytes;
|
|
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
|
|
}
|
|
$this->line(' Done scanning NAS stream cache.');
|
|
}
|
|
|
|
// ── Summary ───────────────────────────────────────────────────────────
|
|
$this->newLine();
|
|
|
|
if (empty($toDelete)) {
|
|
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
|
|
return 0;
|
|
}
|
|
|
|
$this->table(
|
|
['Type', 'File', 'Size'],
|
|
array_map(fn ($row) => [
|
|
$row['label'],
|
|
basename($row['path']),
|
|
$this->humanBytes($row['bytes']),
|
|
], $toDelete)
|
|
);
|
|
|
|
$this->newLine();
|
|
$this->line(sprintf(
|
|
'Found <comment>%d</comment> file(s) totalling <comment>%s</comment> that can be freed.',
|
|
count($toDelete),
|
|
$this->humanBytes($totalBytes)
|
|
));
|
|
|
|
if ($dryRun) {
|
|
$this->newLine();
|
|
$this->warn('Run with --force to delete these files.');
|
|
return 0;
|
|
}
|
|
|
|
// ── Actually delete ───────────────────────────────────────────────────
|
|
$deleted = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($toDelete as $row) {
|
|
if (@unlink($row['path'])) {
|
|
$deleted++;
|
|
Log::info('nas:free-local: deleted ' . $row['path']);
|
|
} else {
|
|
$failed++;
|
|
$this->warn("Could not delete: {$row['path']}");
|
|
}
|
|
}
|
|
|
|
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
|
|
$this->pruneEmptyDirs(storage_path('app/users'));
|
|
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
|
$path = storage_path("app/{$rel}");
|
|
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
|
|
@rmdir($path);
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
|
|
|
if ($failed > 0) {
|
|
$this->warn("{$failed} file(s) could not be deleted — check permissions.");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
|
|
*/
|
|
private function pruneEmptyDirs(string $root): void
|
|
{
|
|
if (! is_dir($root)) return;
|
|
|
|
$iter = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
|
|
foreach ($iter as $item) {
|
|
if (! $item->isDir()) continue;
|
|
$path = $item->getPathname();
|
|
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
|
$nonMeta = array_diff($contents, ['meta.json']);
|
|
if (empty($contents)) {
|
|
@rmdir($path);
|
|
} elseif (empty($nonMeta)) {
|
|
@unlink("{$path}/meta.json");
|
|
@rmdir($path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private function humanBytes(int $bytes): string
|
|
{
|
|
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
|
|
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
|
|
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
|
|
return $bytes . ' B';
|
|
}
|
|
}
|