temp -> final) so the historical primary<->secondary * "swaps" cannot clobber each other. Serving works straight afterwards because * NasSyncService::ensureLocalCopy() prefers the local canonical path. * * Defaults to a DRY RUN. Pass --force to apply. */ class ReorganizeAudioTracks extends Command { protected $signature = 'tracks:reorganize {--force : Apply changes (default is a dry run)}'; protected $description = 'Consolidate audio tracks into one folder per song with unique names, update records, delete orphans + empty folders'; private const AUDIO_EXT = ['mp3', 'm4a', 'aac', 'wav', 'flac', 'ogg', 'opus', 'wma']; /** Media roots (relative to storage/app) that hold ONLY user media — safe to clean. */ private const SCAN_ROOTS = ['users', 'nas_cache/videos', 'public/videos', 'public/thumbnails', 'public/avatars']; private bool $dry = true; private string $appRoot; public function handle(): int { $this->dry = ! $this->option('force'); $this->appRoot = storage_path('app'); $this->info($this->dry ? '=== DRY RUN (no changes) — pass --force to apply ===' : '=== APPLYING CHANGES ==='); $this->newLine(); $plan = $this->buildPlan(); $this->printPlan($plan); if (! $this->dry) { $this->applyPlan($plan); } $finalPaths = $this->finalReferencedPaths($plan); $this->handleOrphans($plan, $finalPaths); $this->handleEmptyDirs(); $this->newLine(); $this->info($this->dry ? 'Dry run complete. Re-run with --force to apply.' : 'Done.'); return self::SUCCESS; } // ── helpers ──────────────────────────────────────────────────────────────── private function isAudio(Video $v): bool { if ($v->mime_type && str_starts_with($v->mime_type, 'audio/')) return true; return in_array(strtolower(pathinfo($v->filename ?? '', PATHINFO_EXTENSION)), self::AUDIO_EXT, true); } private function titleSlug(string $title): string { $title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title; $slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title); $slug = trim(mb_strtolower($slug), '-'); if (mb_strlen($slug) > 100) $slug = rtrim(mb_substr($slug, 0, 100), '-'); return $slug ?: 'video'; } private function songDir(Video $v): string { $path = (string) $v->path; if (str_starts_with($path, 'users/')) { $dir = dirname($path); if (basename($dir) === 'tracks') $dir = dirname($dir); // promoted-primary case return $dir; } $userSlug = $v->user?->username ?: (string) $v->user_id; return 'users/' . $userSlug . '/videos/' . $this->titleSlug($v->title); } private function trackName(string $base, ?string $lang, int $id, string $ext): string { $lang = mb_strtolower(trim((string) $lang)) ?: 'xx'; return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext)); } /** Locate the actual local copy of a file given its DB path + filename. */ private function locate(string $dbPath, string $filename): ?string { foreach ([$dbPath, 'nas_cache/videos/' . $filename] as $cand) { if ($cand && is_file($this->appRoot . '/' . $cand)) return $cand; } return null; } // ── plan ───────────────────────────────────────────────────────────────── private function buildPlan(): array { $plan = []; foreach (Video::with(['user', 'audioTracks'])->get() as $video) { if (! $this->isAudio($video)) continue; $dir = $this->songDir($video); $base = basename($dir); // ── primary ── $pExt = strtolower(pathinfo($video->filename ?: $video->path, PATHINFO_EXTENSION)) ?: 'mp3'; $inDir = str_starts_with((string) $video->path, 'users/') && basename(dirname((string) $video->path)) !== 'tracks'; $pDst = $inDir ? (string) $video->path // already in song folder — keep its name : $dir . '/' . $this->titleSlug($video->title) . '.' . $pExt; // promoted/legacy — canonical name $plan[] = $this->item('primary', $video, (string) $video->path, $video->filename, $pDst); // ── secondaries ── foreach ($video->audioTracks as $t) { $tExt = strtolower(pathinfo($t->filename ?: $t->path, PATHINFO_EXTENSION)) ?: 'mp3'; $tDst = $dir . '/' . $this->trackName($base, $t->language, $t->id, $tExt); $plan[] = $this->item('track', $t, (string) $t->path, $t->filename, $tDst); } } return $plan; } private function item(string $type, $model, string $dbPath, ?string $filename, string $dst): array { $src = $this->locate($dbPath, (string) $filename); return [ 'type' => $type, 'id' => $model->id, 'model' => $model, 'db_path' => $dbPath, 'src' => $src, // actual local file (or null if only on NAS) 'dst' => $dst, 'needsMove' => $src !== null && $src !== $dst, 'atDest' => $src !== null && $src === $dst, 'dbStale' => $dbPath !== $dst, // DB needs updating even if file already at dest ]; } private function printPlan(array $plan): void { $moves = array_filter($plan, fn ($p) => $p['needsMove']); $miss = array_filter($plan, fn ($p) => $p['src'] === null); $this->line('Consolidation / rename plan:'); foreach ($plan as $p) { if ($p['src'] === null) { $this->line(sprintf(' [%s #%d] NO LOCAL COPY (db: %s) — skipped', $p['type'], $p['id'], $p['db_path'])); continue; } if ($p['needsMove']) { $this->line(sprintf(' [%s #%d] %s', $p['type'], $p['id'], $p['src'])); $this->line(sprintf(' -> %s', $p['dst'])); } } $this->newLine(); $this->line(' moves: ' . count($moves) . ' | already correct: ' . (count($plan) - count($moves) - count($miss)) . ' | no local copy: ' . count($miss)); $this->newLine(); } private function applyPlan(array $plan): void { // Phase 1: move every file that needs moving to a unique temp name (collision-safe). $temps = []; foreach ($plan as $i => $p) { if (! $p['needsMove']) continue; $srcAbs = $this->appRoot . '/' . $p['src']; $tmpRel = dirname($p['dst']) . '/.reorg_' . $p['type'] . '_' . $p['id'] . '.tmp'; $tmpAbs = $this->appRoot . '/' . $tmpRel; @mkdir(dirname($tmpAbs), 0755, true); if (@rename($srcAbs, $tmpAbs)) { $temps[$i] = $tmpRel; } else { $this->error(" FAILED phase1 move: {$p['src']}"); } } // Phase 2: temp -> final, then update DB. foreach ($plan as $i => $p) { if (isset($temps[$i])) { $tmpAbs = $this->appRoot . '/' . $temps[$i]; $dstAbs = $this->appRoot . '/' . $p['dst']; if (! @rename($tmpAbs, $dstAbs)) { $this->error(" FAILED phase2 move -> {$p['dst']}"); continue; } $this->line(" moved #{$p['id']} -> {$p['dst']}"); } // Update DB record whenever the stored path/name is out of date. if ($p['src'] !== null && $p['dbStale']) { $p['model']->update(['path' => $p['dst'], 'filename' => basename($p['dst'])]); } } } // ── orphans + empties ────────────────────────────────────────────────────── /** All file paths (relative to storage/app) referenced by the DB after migration. */ private function finalReferencedPaths(array $plan): array { $paths = []; $planned = []; // db_path keyed -> handled by plan foreach ($plan as $p) { if ($p['src'] !== null) { $paths[$p['dst']] = true; $planned[$p['db_path']] = true; } else { $paths[$p['db_path']] = true; } // keep NAS-only refs } foreach (Video::all() as $v) { if ($v->path && ! isset($planned[$v->path]) && ! $this->plannedPrimary($plan, $v->id)) $paths[$v->path] = true; if ($v->thumbnail) $paths[$v->thumbnail] = true; if ($v->slideshow_video_path) $paths[$v->slideshow_video_path] = true; // generated download video } foreach (VideoAudioTrack::all() as $t) { if ($t->path && ! isset($planned[$t->path]) && ! $this->plannedTrack($plan, $t->id)) $paths[$t->path] = true; } foreach (DB::table('video_slides')->pluck('filename') as $f) if ($f) $paths[$f] = true; foreach (User::all() as $u) { if ($u->avatar) $paths[$u->avatar] = true; if ($u->banner) $paths[$u->banner] = true; } foreach (Playlist::all() as $pl) if ($pl->thumbnail) $paths[$pl->thumbnail] = true; foreach (DB::table('post_images')->pluck('filename') as $f) if ($f) $paths[$f] = true; foreach (DB::table('posts')->pluck('image') as $f) if ($f) $paths[$f] = true; return $paths; } private function plannedPrimary(array $plan, int $id): bool { foreach ($plan as $p) if ($p['type'] === 'primary' && $p['id'] === $id && $p['src'] !== null) return true; return false; } private function plannedTrack(array $plan, int $id): bool { foreach ($plan as $p) if ($p['type'] === 'track' && $p['id'] === $id && $p['src'] !== null) return true; return false; } private function handleOrphans(array $plan, array $finalPaths): void { // Conservative: also protect any file whose basename matches a referenced basename // or a planned move-source (sources are being moved, not orphaned). $keepBasenames = ['meta.json' => true]; foreach (array_keys($finalPaths) as $rel) $keepBasenames[basename($rel)] = true; foreach ($plan as $p) if ($p['src']) $keepBasenames[basename($p['src'])] = true; // Files actively moved this run (their old location is vacated, not an orphan to re-check). $movedFrom = []; foreach ($plan as $p) if ($p['needsMove']) $movedFrom[$p['src']] = true; $orphans = []; foreach (self::SCAN_ROOTS as $root) { $abs = $this->appRoot . '/' . $root; if (! is_dir($abs)) continue; $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $file) { if (! $file->isFile()) continue; $rel = substr($file->getPathname(), strlen($this->appRoot) + 1); if (isset($finalPaths[$rel])) continue; if (isset($keepBasenames[$file->getFilename()])) continue; if (isset($movedFrom[$rel])) continue; // Everything under a song's cache/ is a regenerable render — never an orphan. if (str_contains($rel, '/cache/')) continue; $orphans[] = $rel; } } $this->newLine(); if (! $orphans) { $this->line('No orphan files found.'); return; } $this->line('Orphan files (no DB reference) — ' . count($orphans) . ':'); $bytes = 0; foreach ($orphans as $rel) { $abs = $this->appRoot . '/' . $rel; $sz = is_file($abs) ? filesize($abs) : 0; $bytes += $sz; $this->line(sprintf(' %s (%s KB)', $rel, number_format($sz / 1024, 1))); if (! $this->dry) @unlink($abs); } $this->line(' total: ' . number_format($bytes / 1048576, 1) . ' MB' . ($this->dry ? '' : ' (deleted)')); } private function handleEmptyDirs(): void { $this->newLine(); $removed = 0; $listed = []; // Loop because removing leaf dirs can empty their parents. do { $found = 0; foreach (self::SCAN_ROOTS as $root) { $abs = $this->appRoot . '/' . $root; if (! is_dir($abs)) continue; $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $f) { if (! $f->isDir()) continue; if ((new \FilesystemIterator($f->getPathname()))->valid()) continue; // not empty $rel = substr($f->getPathname(), strlen($this->appRoot) + 1); if (isset($listed[$rel])) continue; $listed[$rel] = true; $found++; if (! $this->dry) { @rmdir($f->getPathname()); $removed++; } } } } while (! $this->dry && $found > 0); if (! $listed) { $this->line('No empty folders found.'); return; } $this->line('Empty folders — ' . count($listed) . ':'); foreach (array_keys($listed) as $rel) $this->line(' ' . $rel); if (! $this->dry) $this->line(" ({$removed} removed)"); } }