takeone-youtube-clone/app/Console/Commands/ReorganizeAudioTracks.php
ghassan a4384113c2 Audio songs: one-folder storage, version-aware download/share, GPU-checked renders
Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

326 lines
15 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Major file-structure cleanup for audio "songs":
*
* 1. CONSOLIDATE every song's files into the song's own folder. Each file is
* gathered from wherever its local copy currently is (the canonical path OR
* the nas_cache/) and moved to:
* - primary : kept at its canonical name; promoted/legacy primaries are
* relocated to {song-folder}/{title-slug}.{ext}
* - secondary: {song-folder}/{folder-slug}-{lang}-{track-id}.{ext}
* (lowercase, unique — the db id means nothing can ever overwrite anything,
* and there is no more tracks/ subfolder).
* 2. UPDATE the DB records (path + filename) to match.
* 3. DELETE orphan files (referenced by NO database column) and empty folders
* across the media roots.
*
* Moves are two-phase (src -> 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('<comment>Consolidation / rename plan:</comment>');
foreach ($plan as $p) {
if ($p['src'] === null) {
$this->line(sprintf(' <fg=red>[%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('<comment>Orphan files (no DB reference) — ' . count($orphans) . ':</comment>');
$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('<comment>Empty folders — ' . count($listed) . ':</comment>');
foreach (array_keys($listed) as $rel) $this->line(' ' . $rel);
if (! $this->dry) $this->line(" ({$removed} removed)");
}
}