diff --git a/app/Console/Commands/NasFreeLocalStorage.php b/app/Console/Commands/NasFreeLocalStorage.php new file mode 100644 index 0000000..ab1e8bf --- /dev/null +++ b/app/Console/Commands/NasFreeLocalStorage.php @@ -0,0 +1,146 @@ +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 %d file(s) totalling %s 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'; + } +}