option('force'); $only = $this->option('video') ? (int) $this->option('video') : null; $this->info($apply ? '→ APPLY mode (files and DB will be modified)' : '→ DRY-RUN (no changes will be made)'); $q = Video::query()->with(['user', 'audioTracks', 'slides']); if ($only) $q->where('id', $only); $videos = $q->orderBy('id')->get(); $this->info("Found {$videos->count()} video(s) to inspect."); $stats = ['skipped' => 0, 'planned' => 0, 'applied' => 0, 'errors' => 0]; foreach ($videos as $video) { try { $plan = $this->planVideo($video, $nas); if ($plan === null) { $stats['skipped']++; continue; } $this->line(""); $this->info("Video #{$video->id} ({$video->type}): {$video->title}"); foreach ($plan as $move) { $this->line(" {$move['from']} → {$move['to']}"); } $stats['planned']++; if ($apply) { $this->applyPlan($video, $plan, $nas); $stats['applied']++; } } catch (\Throwable $e) { $stats['errors']++; $this->error("Video #{$video->id}: " . $e->getMessage()); } } $this->line(""); $this->info("Done. " . json_encode($stats)); return self::SUCCESS; } /** * Build the move list for one video. Returns null if it's already in the * target layout. Each plan entry is ['from' => oldPath, 'to' => newPath, * 'kind' => 'video'|'audio'|'slide'|'thumb', 'model' => modelOrNull, * 'slide_field' => 'filename' | 'path' | 'thumbnail']. */ private function planVideo(Video $video, NasSyncService $nas): ?array { $path = (string) $video->path; if (! str_starts_with($path, 'users/')) { return null; // unorganised — outside this migration's scope } $segs = explode('/', $path); if (count($segs) < 4) return null; $expectedTypeFolder = $nas->typeFolder($video); $currentTypeFolder = $segs[2] ?? null; $isMusic = ($video->type === 'music'); // Already in target layout? Music: tracks/{lang-id}/audio.{ext}. Others: video.{ext}. $inTracks = ($segs[4] ?? null) === 'tracks'; $canonicalPrimary = $isMusic ? 'audio' : 'video'; $endsCanonical = preg_match("#/{$canonicalPrimary}\\.[a-z0-9]+$#i", $path) === 1; if ($currentTypeFolder === $expectedTypeFolder && (! $isMusic || $inTracks) && $endsCanonical) { return null; // already migrated } // Compute new song/video root: users/{slug}/{type-folder}/{slug} $userSlug = $segs[1]; $videoSlug = $isMusic ? ($segs[3] ?? null) : ($segs[3] ?? null); if (! $videoSlug) return null; $newRoot = "users/{$userSlug}/{$expectedTypeFolder}/{$videoSlug}"; $ext = pathinfo($video->filename ?: $path, PATHINFO_EXTENSION) ?: 'mp4'; $plan = []; // Primary file if ($isMusic) { $primaryFolder = $nas->trackFolderName($video, null); $plan[] = [ 'from' => $path, 'to' => "{$newRoot}/tracks/{$primaryFolder}/audio.{$ext}", 'kind' => 'video', 'model' => $video, 'set' => ['path', 'filename'], 'new_filename'=> "audio.{$ext}", ]; } else { $plan[] = [ 'from' => $path, 'to' => "{$newRoot}/video.{$ext}", 'kind' => 'video', 'model' => $video, 'set' => ['path', 'filename'], 'new_filename'=> "video.{$ext}", ]; } // Thumbnail if ($video->thumbnail && str_starts_with($video->thumbnail, 'users/')) { $thumbExt = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'webp'; $thumbDir = $isMusic ? "{$newRoot}/tracks/" . $nas->trackFolderName($video, null) : $newRoot; $plan[] = [ 'from' => $video->thumbnail, 'to' => "{$thumbDir}/thumb.{$thumbExt}", 'kind' => 'thumb', 'model' => $video, 'set' => ['thumbnail'], ]; } // Extra audio tracks (music only) if ($isMusic) { foreach ($video->audioTracks as $track) { if (! str_starts_with((string) $track->path, 'users/')) continue; $tExt = pathinfo($track->filename ?: $track->path, PATHINFO_EXTENSION) ?: 'mp3'; $trackFolder = $nas->trackFolderName($video, $track); $plan[] = [ 'from' => $track->path, 'to' => "{$newRoot}/tracks/{$trackFolder}/audio.{$tExt}", 'kind' => 'audio', 'model' => $track, 'set' => ['path', 'filename'], 'new_filename'=> "audio.{$tExt}", ]; } // Slides (music only) — owners may be NULL (primary), or any track id foreach ($video->slides as $slide) { if (! str_starts_with((string) $slide->filename, 'users/')) continue; $sExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg'; $ownerTrack = $slide->audio_track_id ? $video->audioTracks->firstWhere('id', $slide->audio_track_id) : null; $folder = $nas->trackFolderName($video, $ownerTrack); $plan[] = [ 'from' => $slide->filename, 'to' => "{$newRoot}/tracks/{$folder}/slides/{$slide->position}.{$sExt}", 'kind' => 'slide', 'model' => $slide, 'set' => ['filename'], ]; } } return $plan; } /** * Execute one video's plan. On NAS: copy then delete (smbclient has no rename). * On local: rename(). Either way, DB columns are updated after the move succeeds. */ private function applyPlan(Video $video, array $plan, NasSyncService $nas): void { $nasOn = $nas->isEnabled(); foreach ($plan as $move) { // Ensure target dir exists $targetDir = dirname($move['to']); if ($nasOn) { $nas->mkdirp($targetDir); } @mkdir(storage_path('app/' . $targetDir), 0755, true); // Move on local $localFrom = storage_path('app/' . $move['from']); $localTo = storage_path('app/' . $move['to']); if (is_file($localFrom)) { @rename($localFrom, $localTo); } // Move on NAS via copy+delete if ($nasOn) { $tmp = tempnam(sys_get_temp_dir(), 'mig_'); if ($nas->getContent($move['from']) !== null) { // small file like .json — already cached as content; round-trip } // For binary files, pull → push → delete-source $localCache = storage_path('app/' . $move['from']); if (! is_file($localCache)) { $nas->ensureLocalAsset($localCache, $move['from']); } if (is_file($localCache)) { if ($nas->putFile($localCache, $move['to'])) { $nas->deleteFile($move['from']); } } @unlink($tmp); } // Update DB $model = $move['model']; $updates = []; foreach ($move['set'] as $col) { if ($col === 'path' || $col === 'thumbnail' || $col === 'filename') { $updates[$col] = ($col === 'filename' && isset($move['new_filename'])) ? $move['new_filename'] : $move['to']; } } // VideoSlide stores the full path under `filename` if ($model instanceof VideoSlide) { $model->update(['filename' => $move['to']]); } else { $model->update($updates); } } } }