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>
242 lines
9.4 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|
|
}
|