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';
}
}