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>
147 lines
4.9 KiB
PHP
147 lines
4.9 KiB
PHP
<?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';
|
|
}
|
|
}
|