takeone-youtube-clone/app/Console/Commands/MigrateStorageLayout.php
ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

242 lines
9.4 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
/**
* One-time migration that moves existing songs/videos from the legacy flat
* layout into the canonical type-segregated, per-track-folder layout described
* in CLAUDE.md.
*
* Dry-run by default — pass --force to actually move files and update the DB.
* Run this only after backing up the DB and confirming the dry-run plan looks
* correct. The command is idempotent: rows already in the new layout are
* skipped.
*/
class MigrateStorageLayout extends Command
{
protected $signature = 'storage:migrate-layout {--force : Apply changes (default is dry-run)} {--video= : Migrate only this video id}';
protected $description = 'Move existing songs/videos to the type-segregated + per-track-folder layout';
public function handle(NasSyncService $nas): int
{
$apply = (bool) $this->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);
}
}
}
}