isEnabled()) { $this->error('NAS sync is not enabled. Enable it in Admin → Settings first.'); return 1; } $this->nas = $nas; $dryRun = $this->option('dry-run'); $force = $this->option('force'); if (! $dryRun && ! $force) { $this->warn('Pass --dry-run to preview, or --force to repair.'); return 1; } $this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies'); $this->newLine(); $totalRepaired = 0; $totalFailed = 0; // ── NAS-format videos / slides / thumbnails ─────────────────────────── $stuckVideos = $this->findStuckVideos(); if ($stuckVideos->isNotEmpty()) { $this->info('Video / slide files stuck locally:'); $rows = []; foreach ($stuckVideos as $item) { foreach ($item['files'] as $label) { $rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label]; } } $this->table(['Video ID', 'Title', 'File'], $rows); $this->newLine(); if ($force) { foreach ($stuckVideos as $item) { $video = $item['video']; $this->line(" Repairing video #{$video->id}: {$video->title}…"); try { $nas->syncVideo($video); $nas->deleteLocalAssets($video); if ($video->hls_path || $video->type === 'music') { $nas->deleteLocalVideo($video); } $nas->pruneLocalVideoDir($video); $totalRepaired++; $this->line(' ✓ Done'); Log::info("nas:repair: fixed video #{$video->id}"); } catch (\Throwable $e) { $totalFailed++; $this->warn(" ✗ Failed: {$e->getMessage()}"); Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage()); } } $this->newLine(); } } // ── Avatars ─────────────────────────────────────────────────────────── $stuckAvatars = $this->findStuckAvatars(); if ($stuckAvatars->isNotEmpty()) { $this->info('Avatar files stuck locally:'); $this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [ $r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'], ])->all()); $this->newLine(); if ($force) { foreach ($stuckAvatars as $item) { $user = $item['user']; $path = $item['path']; $this->line(" Repairing avatar for {$user->username}…"); try { $nas->syncAvatar($user, $path); $nas->deleteLocalAvatar($user); $totalRepaired++; $this->line(' ✓ Done'); } catch (\Throwable $e) { $totalFailed++; $this->warn(" ✗ Failed: {$e->getMessage()}"); Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage()); } } $this->newLine(); } } // ── Banners ─────────────────────────────────────────────────────────── $stuckBanners = $this->findStuckBanners(); if ($stuckBanners->isNotEmpty()) { $this->info('Banner files stuck locally:'); $this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [ $r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'], ])->all()); $this->newLine(); if ($force) { foreach ($stuckBanners as $item) { $user = $item['user']; $path = $item['path']; $this->line(" Repairing banner for {$user->username}…"); try { $nas->syncCover($user, $path); $nas->deleteLocalBanner($user); $totalRepaired++; $this->line(' ✓ Done'); } catch (\Throwable $e) { $totalFailed++; $this->warn(" ✗ Failed: {$e->getMessage()}"); Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage()); } } $this->newLine(); } } // ── Legacy flat thumbnails (public/thumbnails/) ─────────────────────── $stuckThumbs = $this->findStuckLegacyThumbnails(); if ($stuckThumbs->isNotEmpty()) { $this->info('Legacy thumbnail/slide files stuck locally:'); $this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [ $r['type'], $r['file'], $r['video_id'], ])->all()); $this->newLine(); if ($force) { foreach ($stuckThumbs as $item) { $this->line(" Repairing {$item['type']}: {$item['file']}…"); try { if ($item['type'] === 'thumbnail' && $item['video']) { $nas->syncVideo($item['video']); } elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) { // Upload slide directly to NAS $video = $item['video']; $slide = $item['slide']; $dir = $nas->resolveVideoDir($video); $ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg'; $nas->mkdirp("{$dir}/slides"); $nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}"); } @unlink($item['path']); $totalRepaired++; $this->line(' ✓ Done'); } catch (\Throwable $e) { $totalFailed++; $this->warn(" ✗ Failed: {$e->getMessage()}"); Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage()); } } $this->newLine(); } } $anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty() || $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty(); // ── NAS orphaned video folders ───────────────────────────────────────── $this->newLine(); $this->info('Scanning NAS for orphaned video folders (no matching DB record)…'); $nasOrphans = $nas->scanNasOrphans(); if (! empty($nasOrphans)) { $this->table( ['NAS Directory', 'meta.json video_id'], array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans) ); $this->newLine(); if ($force) { $deletedOrphans = 0; $failedOrphans = 0; foreach ($nasOrphans as $orphan) { $this->line(" Deleting NAS orphan: {$orphan['dir']}…"); try { $nas->deleteNasTree($orphan['dir']); $deletedOrphans++; $this->line(' ✓ Deleted'); Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]); } catch (\Throwable $e) { $failedOrphans++; $this->warn(" ✗ Failed: {$e->getMessage()}"); Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]); } } $totalRepaired += $deletedOrphans; $totalFailed += $failedOrphans; $this->newLine(); } } else { $this->line(' No orphaned NAS folders found.'); } $anyIssues = $anyStuck || ! empty($nasOrphans); // ── NAS stream cache ────────────────────────────────────────────────── $cacheBytes = $nas->nasCacheSize(); if ($cacheBytes > 0) { $ttl = (int) $this->option('cache-ttl'); $ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h"; $this->info(sprintf( 'NAS stream cache: %s occupying %s — will be evicted on --force (%s).', count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)', $this->humanBytes($cacheBytes), $ttlLabel )); $this->newLine(); } if (! $anyIssues && $cacheBytes === 0) { $this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.'); } elseif (! $anyIssues) { $this->info('No issues found (stream cache will be evicted on --force).'); } if ($dryRun && ($anyIssues || $cacheBytes > 0)) { $this->warn('Run with --force to repair.'); return 0; } if ($force) { $this->pruneAllLocalDirs(); $ttl = (int) $this->option('cache-ttl'); $evicted = $nas->clearNasCache($ttl); if ($evicted > 0) { $this->line("Evicted {$evicted} NAS stream-cache file(s)."); } } if ($totalRepaired > 0 || $force) { $this->newLine(); if ($totalFailed === 0) { $this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up."); } else { $this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details."); } } return $totalFailed > 0 ? 1 : 0; } // ── Scanners ────────────────────────────────────────────────────────────── private function findStuckVideos(): \Illuminate\Support\Collection { return Video::with(['user', 'slides'])->get() ->filter(fn (Video $v) => str_starts_with($v->path, 'users/')) ->map(function (Video $video) { $files = []; if (file_exists(storage_path('app/' . $video->path))) $files[] = basename($video->path) . ' (video)'; if ($video->thumbnail && str_contains($video->thumbnail, '/') && file_exists(storage_path('app/' . $video->thumbnail))) $files[] = basename($video->thumbnail) . ' (thumbnail)'; foreach ($video->slides as $slide) { if (file_exists($slide->localPath())) $files[] = basename($slide->filename) . " (slide #{$slide->position})"; } return $files ? ['video' => $video, 'files' => $files] : null; }) ->filter(); } private function findStuckAvatars(): \Illuminate\Support\Collection { $results = collect(); // Legacy flat dir: public/avatars/ $dir = storage_path('app/public/avatars'); if (is_dir($dir)) { $flat = collect(scandir($dir) ?: []) ->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}")) ->map(function ($filename) use ($dir) { $user = User::where('avatar', $filename)->first(); return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null; }) ->filter(); $results = $results->merge($flat); } // New structured dir: users/{slug}/profile/avatar.* $usersBase = storage_path('app/users'); if (is_dir($usersBase)) { foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) { $relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/'); // relPath = "users/{slug}/profile/avatar.{ext}" $user = User::where('avatar', $relPath)->first(); if ($user) { $results->push(['user' => $user, 'file' => basename($path), 'path' => $path]); } } } return $results; } private function findStuckBanners(): \Illuminate\Support\Collection { $results = collect(); // Legacy flat dir: public/banners/ $dir = storage_path('app/public/banners'); if (is_dir($dir)) { $flat = collect(scandir($dir) ?: []) ->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}")) ->map(function ($filename) use ($dir) { $user = User::where('banner', $filename)->first(); return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null; }) ->filter(); $results = $results->merge($flat); } // New structured dir: users/{slug}/profile/cover.* $usersBase = storage_path('app/users'); if (is_dir($usersBase)) { foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) { $relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/'); $user = User::where('banner', $relPath)->first(); if ($user) { $results->push(['user' => $user, 'file' => basename($path), 'path' => $path]); } } } return $results; } private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection { $dir = storage_path('app/public/thumbnails'); if (! is_dir($dir)) return collect(); $results = []; foreach (scandir($dir) ?: [] as $filename) { if ($filename === '.' || $filename === '..') continue; $path = "{$dir}/{$filename}"; if (! is_file($path)) continue; // Check if it's a video thumbnail $video = Video::where('thumbnail', $filename)->first(); if ($video) { $results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null]; continue; } // Check if it's an old-format slide $slide = VideoSlide::where('filename', $filename)->with('video')->first(); if ($slide && $slide->video) { $results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide]; } } return collect($results); } // ── Directory pruning ───────────────────────────────────────────────────── private function pruneAllLocalDirs(): void { // NAS-mirrored dirs $this->pruneEmptyDirTree(storage_path('app/users')); // Flat asset dirs — remove if empty foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) { $path = storage_path("app/{$rel}"); if (is_dir($path) && $this->isDirEmpty($path)) { @rmdir($path); } } } /** * Bottom-up prune: remove dirs that contain only meta.json or nothing. */ private function pruneEmptyDirTree(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); } } } private function isDirEmpty(string $dir): bool { return empty(array_diff(scandir($dir) ?: [], ['.', '..'])); } 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'; } }