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…'); $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(); 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 %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']); } 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'; } }