Add nas:free-local command to remove local files already on NAS

Scans all videos that still have a local file, checks NAS for meta.json
(written last in syncVideo, so its presence confirms a complete push),
then removes the local copy if confirmed.

Usage:
  php artisan nas:free-local --dry-run   # preview
  php artisan nas:free-local --force     # delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-14 01:59:47 +03:00
parent 0b75acec89
commit 296d605864

View File

@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
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}';
protected $description = 'Delete local video files that are 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();
// Only videos that still have a local file
$videos = Video::all()->filter(function (Video $v) {
return file_exists(storage_path('app/' . $v->path));
});
if ($videos->isEmpty()) {
$this->info('No local video files found — nothing to do.');
return 0;
}
$this->info("Checking {$videos->count()} local file(s) against NAS…");
$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.');
return 0;
}
$this->table(
['ID', 'Title', 'Local file', 'Size'],
array_map(fn ($row) => [
$row['video']->id,
\Illuminate\Support\Str::limit($row['video']->title, 40),
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'], ['video_id' => $row['video']->id]);
} else {
$failed++;
$this->warn("Could not delete: {$row['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;
}
// ── 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';
}
}