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.'); // ── Generated/derived renders inside song folders ───────────────────── // Everything under a song's cache/ subfolder (download videos + HLS) is a // render that regenerates on demand, so it is always safe to delete to free // space. Sources (audio, tracks, slides) live outside cache/ and are untouched. $this->newLine(); $this->info('Scanning generated renders (song cache/ folders)…'); $usersRoot = storage_path('app/users'); if (is_dir($usersRoot)) { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (! $file->isFile()) continue; $path = $file->getPathname(); if (! str_contains($path, '/cache/')) continue; $bytes = $file->getSize(); $totalBytes += $bytes; $toDelete[] = ['label' => str_contains($path, '/cache/hls/') ? 'hls' : 'download-video', 'path' => $path, 'bytes' => $bytes]; } $this->line(' Done scanning generated renders.'); } // ── 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 %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']}"); } } // 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'; } }