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>
326 lines
15 KiB
PHP
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)");
|
|
}
|
|
}
|