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 <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-31 22:01:47 +03:00
1 parent 73527f3781
commit f98e5415a3
60 files changed
+7920 -2675

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Jobs\GenerateLyricsJob;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
/**
* Backfill synced lyrics for existing songs. New uploads generate automatically;
* this covers the catalogue that predates the feature.
*
* php artisan lyrics:generate 163 # one video (primary + every track)
* php artisan lyrics:generate --all # every music video missing lyrics
* php artisan lyrics:generate --all --force # regenerate even if a file exists
*/
class GenerateLyrics extends Command
{
protected $signature = 'lyrics:generate {video? : Video id} {--all : All music videos} {--force : Regenerate even when lyrics already exist}';
protected $description = 'Generate word-level synced lyrics for songs (dispatched to the video-processing queue)';
public function handle(NasSyncService $nas): int
{
$force = (bool) $this->option('force');
if ($videoId = $this->argument('video')) {
$video = Video::find($videoId);
if (! $video) {
$this->error("Video #{$videoId} not found.");
return self::FAILURE;
}
$videos = collect([$video]);
} elseif ($this->option('all')) {
$videos = Video::where('type', 'music')->get();
} else {
$this->error('Pass a video id or --all.');
return self::FAILURE;
}
$dispatched = 0;
foreach ($videos as $video) {
$video->loadMissing('audioTracks');
if ($force || ! is_array($nas->getLyrics($video, null))) {
GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
$dispatched++;
}
foreach ($video->audioTracks as $track) {
if ($force || ! is_array($nas->getLyrics($video, $track))) {
GenerateLyricsJob::dispatch($video->id, $track->id)->onConnection('database');
$dispatched++;
}
}
$this->line("Queued lyrics for #{$video->id} — {$video->title}");
}
$this->info("Dispatched {$dispatched} lyrics job(s) to the video-processing queue.");
return self::SUCCESS;
}
}
@@ -0,0 +1,241 @@
<?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);
}
}
}
}
+29 -3
View File
@@ -28,9 +28,8 @@ class PlaylistController extends Controller
}
// View a single playlist
public function show(Playlist $playlist)
public function show(Request $request, Playlist $playlist)
{
// Check if user can view this playlist
if (! $playlist->canView(Auth::user())) {
abort(404, 'Playlist not found');
}
@@ -38,11 +37,17 @@ class PlaylistController extends Controller
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
// Count this visit (deduped per device) after the response is sent so
// the page render isn't blocked by the EXISTS / INSERT / UPDATE round-trip.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos'));
}
// View playlist via unguessable share token (unlisted playlists)
public function showByToken(string $token)
public function showByToken(Request $request, string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
@@ -53,6 +58,10 @@ class PlaylistController extends Controller
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos'));
}
@@ -137,6 +146,11 @@ class PlaylistController extends Controller
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Human share-link click counts as a playlist view (deduped per device).
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@@ -336,6 +350,18 @@ class PlaylistController extends Controller
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
}
/**
* Body-based mirror of removeVideo: looks up the Video by raw numeric id
* from the request body. Lets callers (e.g. the add-to-playlist modal) drive
* the toggle without having to know the encoded route key for Video.
*/
public function removeVideoByBody(Request $request, Playlist $playlist)
{
$request->validate(['video_id' => 'required|exists:videos,id']);
$video = Video::findOrFail($request->video_id);
return $this->removeVideo($request, $playlist, $video);
}
// Remove video from playlist
public function removeVideo(Request $request, Playlist $playlist, Video $video)
{
+255 -35
View File
@@ -846,48 +846,204 @@ class SuperAdminController extends Controller
public function settings()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
'llm_enabled' => Setting::get('llm_enabled', 'false'),
'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
'llm_active_id' => (string) Setting::get('llm_active_id', ''),
];
return view('admin.settings', compact('settings'));
}
/**
* Settings save handler — accepts partial submissions from any of the
* separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
* that appear in the request.
*/
public function updateSettings(Request $request)
{
// ── GPU section ──────────────────────────────────────────────────────
if ($request->has('gpu_enabled')) {
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
Setting::flushGpuProbe();
}
// ── Lyrics pipeline section ──────────────────────────────────────────
if ($request->has('lyrics_section')) {
foreach ([
'lyrics_enabled', // master switch
'lyrics_use_description', // align to description text
'lyrics_vad_enabled', // Silero VAD filter
'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
'lyrics_demucs_enabled', // vocal isolation (Demucs)
'lyrics_llm_decorate', // post-bake emojis via LLM
] as $k) {
Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
}
}
// ── AI / LLM section ─────────────────────────────────────────────────
if ($request->has('llm_section')) {
Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
$this->saveLlmProviders($request);
}
return back()->with('success', 'Settings saved.');
}
/**
* Probe an LLM provider endpoint: verify the connection and list
* available models. Used by the AI / LLM settings page.
*
* Accepts kind / endpoint / api_key from the form, plus an optional
* provider id so we can fall back to the saved key when the admin
* left the password field blank (placeholder ••••••••).
*/
public function llmProviderTest(Request $request)
{
$kind = (string) $request->input('kind', 'ollama');
$endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
$endpoint = rtrim($endpoint, '/');
$apiKey = (string) $request->input('api_key', '');
$id = (string) $request->input('id', '');
if ($apiKey === '' && $id !== '') {
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
foreach ($providers as $p) {
if (($p['id'] ?? '') === $id) {
$apiKey = (string) ($p['api_key'] ?? '');
break;
}
}
}
if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
}
if ($kind !== 'ollama' && $apiKey === '') {
return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
}
try {
$models = match ($kind) {
'ollama' => $this->fetchOllamaModels($endpoint),
'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
};
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()]);
}
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
return response()->json([
'ok' => true,
'count' => count($models),
'models' => $models,
]);
}
private function fetchOllamaModels(string $endpoint): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
if (! $resp->successful()) {
throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['name'] ?? ''),
$j['models'] ?? []
)));
}
private function fetchAnthropicModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
private function fetchOpenAIModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)
->withToken($apiKey)->acceptJson()
->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
public function lyrics()
{
$settings = [
'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
];
return view('admin.lyrics', compact('settings'));
}
public function gpu()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
}
public function updateSettings(Request $request)
public function backup()
{
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
// GPU config changed — drop the cached health-check so the next encode re-probes.
Setting::flushGpuProbe();
return back()->with('success', 'Settings saved.');
return view('admin.backup');
}
public function detectGpu()
@@ -895,6 +1051,67 @@ class SuperAdminController extends Controller
return response()->json(['gpus' => $this->probeGpus()]);
}
/**
* Persist the LLM provider list from the multi-provider form. Each row
* carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
* api_key. An empty api_key means "keep the previously stored value" so the
* admin doesn't have to retype it on every save.
*/
private function saveLlmProviders(Request $request): void
{
$existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
->keyBy(fn ($p) => $p['id'] ?? '');
$kinds = ['ollama', 'anthropic', 'openai'];
$rows = (array) $request->input('providers', []);
$out = [];
foreach ($rows as $row) {
$name = trim((string) ($row['name'] ?? ''));
$kind = (string) ($row['kind'] ?? 'ollama');
if (! in_array($kind, $kinds, true)) $kind = 'ollama';
if ($name === '') continue;
$id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
$endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
$model = trim((string) ($row['model'] ?? ''));
$apiKeyIn = (string) ($row['api_key'] ?? '');
// Blank input → keep the previously-stored key for this id (admin
// didn't retype it). Non-blank → use the new value verbatim.
$apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
$out[] = [
'id' => $id,
'name' => $name,
'kind' => $kind,
'endpoint' => $endpoint,
'model' => $model,
'api_key' => $apiKey,
];
}
Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
$activeId = trim((string) $request->input('llm_active_id', ''));
$validIds = array_column($out, 'id');
if ($activeId !== '' && in_array($activeId, $validIds, true)) {
Setting::set('llm_active_id', $activeId);
} elseif (count($validIds) === 1) {
Setting::set('llm_active_id', $validIds[0]);
} elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
Setting::set('llm_active_id', '');
}
}
private static function defaultEndpoint(string $kind): string
{
return match ($kind) {
'anthropic' => 'https://api.anthropic.com',
'openai' => 'https://api.openai.com',
default => 'http://localhost:11434',
};
}
private function probeGpus(): array
{
$gpus = [];
@@ -935,7 +1152,10 @@ class SuperAdminController extends Controller
public function nasStorage()
{
$nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes'));
$settings = [
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
];
return view('admin.nas-storage', compact('nodes', 'settings'));
}
public function nasDelete(Request $request)
+397 -40
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
}
public function index()
@@ -161,6 +161,12 @@ class VideoController extends Controller
'extra_track_titles.*' => 'nullable|string|max:255',
'extra_track_descriptions' => 'nullable|array',
'extra_track_descriptions.*'=> 'nullable|string',
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
// If absent for a given index, the track inherits the primary's slides
// at render time via Video::slidesForTrack().
'extra_track_slides' => 'nullable|array',
'extra_track_slides.*' => 'nullable|array',
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$videoFile = $request->file('video');
@@ -332,6 +338,7 @@ class VideoController extends Controller
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
$trackSlides = $request->file('extra_track_slides') ?: [];
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
@@ -354,19 +361,20 @@ class VideoController extends Controller
if ($nas->isEnabled()) {
try {
$videoDir = $nas->resolveVideoDir($video);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
// Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
$trackDir = $nas->trackDir($video, $track);
$nas->mkdirp($trackDir);
$canonical = "audio.{$ext}";
$nasPath = "{$trackDir}/{$canonical}";
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => $trackName,
'filename' => $canonical,
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
@@ -376,6 +384,23 @@ class VideoController extends Controller
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
// ── Optional per-track slides ──────────────────────────────
// The track only owns slides that were uploaded for it. If none
// were uploaded, the player falls back to the primary's at render
// time via Video::slidesForTrack — no row needed here.
$files = $trackSlides[$i] ?? null;
if (is_array($files) && count($files) > 0) {
$this->storeTrackSlides($video, $track, $files, $nas);
}
}
}
// ── Synced lyrics generation (audio/music uploads only) ───────────────
if ($isAudioUpload) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
foreach ($video->audioTracks()->pluck('id') as $tid) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
}
}
@@ -544,9 +569,19 @@ class VideoController extends Controller
if ($playlistParam) {
$playlist = Playlist::where('share_token', $playlistParam)->first();
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
$nextVideo = $playlist->getNextVideo($video);
$previousVideo = $playlist->getPreviousVideo($video);
$playlistVideos = $playlist->videos;
// Load the videos ONCE with their owners eager-loaded, then
// compute prev/next in PHP. The old code fired 4+ separate
// queries for prev/next/list — the sidebar lag the user
// reported was almost entirely those extra round-trips.
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
// Count the playlist view (deduped per device, 1-hour window)
// after the response is flushed so we don't pay the round-trip
// on the hot path.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
}
}
@@ -579,9 +614,16 @@ class VideoController extends Controller
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
$slides = $video->slides->count() > 1
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
// Per-track slide map (key "0" = primary). Each entry already has the
// sharing fallback applied by Video::slidesForTrack — a track without its
// own slides borrows the primary's (or a sibling's) automatically.
$slideMap = ['0' => $video->slidesForTrack(null)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
foreach ($video->audioTracks as $_t) {
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
}
$slides = $slideMap['0'];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
@@ -595,6 +637,14 @@ class VideoController extends Controller
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all();
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
// Local mirror only — must not block this hot path on NAS I/O.
$nasLyrics = app(\App\Services\NasSyncService::class);
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
foreach ($video->audioTracks as $t) {
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
}
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
@@ -604,6 +654,7 @@ class VideoController extends Controller
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
'slide_map' => $slideMap,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
@@ -612,9 +663,212 @@ class VideoController extends Controller
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
'lyrics' => $lyricsMap,
]);
}
/**
* Owner-triggered lyrics generation for the current audio track (?track={id},
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
* polls player-data and shows the lyrics once the file lands.
*/
public function generateLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
}
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
}
$trackId = (int) $request->input('track', 0);
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
return response()->json(['status' => 'queued']);
}
/**
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
// Clear any in-flight progress / temp artifacts so a queued job that
// fires later can't repopulate stale output.
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
return response()->json(['status' => 'deleted']);
}
/**
* Live progress for an in-flight lyrics generation, driving the player's
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
*/
public function lyricsProgress(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['status' => 'none']);
}
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
if (is_array($data)) {
$st = $data['status'] ?? 'ready';
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
if ($st === 'failed') return response()->json(['status' => 'failed']);
}
// Live percentage from the pipeline's progress file.
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
if (is_file($progFile)) {
$p = json_decode((string) file_get_contents($progFile), true);
if (is_array($p)) {
return response()->json([
'status' => 'processing',
'pct' => (int) ($p['pct'] ?? 1),
'stage' => $p['stage'] ?? 'Working',
]);
}
}
// A 'processing' lyrics marker but no progress file yet → just queued.
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
return response()->json(['status' => 'none']);
}
/**
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
* track; preserves precise word timing for lines that weren't changed and
* redistributes timing evenly across the new words for edited lines.
*/
public function saveLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$existing = $nas->getLocalLyrics($video, $track) ?: [];
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
$inLines = $request->input('lines', []);
if (! is_array($inLines)) $inLines = [];
$out = [];
foreach ($inLines as $ln) {
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
if ($text === '' || mb_strlen($text) > 1000) {
if ($text === '') continue;
$text = mb_substr($text, 0, 1000);
}
$start = round((float) ($ln['start'] ?? 0), 3);
$end = round((float) ($ln['end'] ?? $start), 3);
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
$isSpaceless = in_array($lang, $spaceless, true);
// Keep original word timings if the text wasn't changed; otherwise
// redistribute the line's span evenly across the new tokens.
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
$sep = $isSpaceless ? '' : ' ';
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
$unchanged = $origWords
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
if ($unchanged) {
$words = array_map(fn ($w) => [
'start' => round((float) ($w['start'] ?? $start), 3),
'end' => round((float) ($w['end'] ?? $end), 3),
'text' => (string) ($w['text'] ?? ''),
], $origWords);
} else {
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
}
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
if (count($out) >= 1000) break;
}
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'ready',
'source' => 'edited',
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
'multilingual' => $existing['multilingual'] ?? false,
'lines' => $out,
'generated_at' => now()->toIso8601String(),
]);
return response()->json(['status' => 'ok', 'lines' => count($out)]);
}
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
{
$text = trim($text);
if ($text === '' || $end <= $start) {
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
}
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
$n = count($tokens);
if ($n === 0) return [];
$slice = ($end - $start) / $n;
$words = [];
foreach ($tokens as $i => $tok) {
$words[] = [
'start' => round($start + $i * $slice, 3),
'end' => round($start + ($i + 1) * $slice, 3),
'text' => $tok,
];
}
return $words;
}
public function matchData(Video $video)
{
if (! $video->canView(Auth::user())) {
@@ -639,8 +893,9 @@ class VideoController extends Controller
}
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
'id' => $s->id,
'url' => $s->url,
'id' => $s->id,
'url' => $s->url,
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
@@ -1485,21 +1740,93 @@ class VideoController extends Controller
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{
try {
// All tracks (primary + secondary) live directly in the song's own folder —
// never a separate tracks/ subfolder — with a unique, lowercase name.
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
$base = basename($localDir);
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
$trackFile->move($localDir, $trackName);
$userSlug = $nas->userSlug($video->user);
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
$track->update(['path' => $relPath, 'filename' => $trackName]);
// Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
// (storeTrackLocally is only reached for music since extra tracks
// only exist on music videos.)
$songLocalDir = $nas->localVideoDir($video);
$trackFolder = $nas->trackFolderName($video, $track);
$trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
@mkdir($trackDirAbs, 0755, true);
$canonical = "audio.{$ext}";
$trackFile->move($trackDirAbs, $canonical);
// Build the NAS-relative path from the song's relative path so it
// works on both local-only and NAS-enabled setups.
$songRel = $this->relFromStoragePath($songLocalDir);
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
$track->update(['path' => $relPath, 'filename' => $canonical]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
/** Convert an absolute storage_path() value back to a storage-relative path. */
private function relFromStoragePath(string $abs): string
{
$prefix = storage_path('app/');
if (str_starts_with($abs, $prefix)) {
return substr($abs, strlen($prefix));
}
return $abs;
}
/**
* Persist per-track slides — the new track owns them via audio_track_id.
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
* slides/track-{trackId}-{position}.{ext}
* Primary slides keep the legacy slides/{position}.{ext} scheme.
*/
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
{
// Per-track slides live in the track's own folder:
// tracks/{lang-id}/slides/{position}.{ext}
// Filenames are canonical (just {position}.{ext}) because the folder
// already disambiguates by track.
$nasEnabled = $nas->isEnabled();
$songLocal = $nas->localVideoDir($video);
$songRel = $this->relFromStoragePath($songLocal);
$trackFold = $nas->trackFolderName($video, $track);
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
if ($nasEnabled) {
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
} else {
@mkdir("{$localTrackDir}/slides", 0755, true);
}
foreach ($files as $pos => $file) {
if (! $file || ! $file->isValid()) continue;
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$name = "{$pos}.{$ext}";
$slide = VideoSlide::create([
'video_id' => $video->id,
'audio_track_id' => $track->id,
'filename' => '__pending__',
'position' => $pos,
]);
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasEnabled) {
$nasPath = "{$nasTrackDir}/slides/{$name}";
if ($nas->putFile($tempAbs, $nasPath)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasPath]);
continue;
}
// Fall through to local on NAS failure
}
@mkdir("{$localTrackDir}/slides", 0755, true);
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
}
}
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
@@ -1602,10 +1929,11 @@ class VideoController extends Controller
// Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
$lyrics = request()->boolean('lyrics');
// Any non-primary or visualizer variant is served straight off disk (no DB column).
if ($viz || $trackId) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
// Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
if ($viz || $trackId || $lyrics) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
@@ -1707,7 +2035,8 @@ class VideoController extends Controller
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$lyrics = $request->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
@@ -1731,7 +2060,7 @@ class VideoController extends Controller
return response()->json(['error' => 'Audio file not found'], 404);
}
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
$outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
@@ -1772,12 +2101,37 @@ class VideoController extends Controller
return response()->json(['error' => 'Cannot probe audio duration'], 500);
}
$slides = $video->slides()->orderBy('position')->get();
// Use the slides for the track being rendered, applying the per-track
// sharing fallback (own → primary → sibling). Ensures the download .mp4
// matches the slideshow the listener saw in the player.
$video->loadMissing('slides');
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
// track and weave it into the final video output. $voutLabel/$assArg/
// $assTail let each render branch inject the burn uniformly.
$voutLabel = '[vout]';
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
if ($lyrics) {
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
$assFilter = 'ass=' . $assPath;
$assArg = ',' . $assFilter;
$assTail = ';[vout]' . $assFilter . '[vsub]';
$voutLabel = '[vsub]';
}
}
}
@unlink($progressFile);
@unlink($pidFile);
@@ -1850,10 +2204,10 @@ class VideoController extends Controller
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts);
$fc = implode(';', $fcParts) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $audioIdx . ':a'
. ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@@ -1881,10 +2235,10 @@ class VideoController extends Controller
$prev = $outLabel;
}
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
$fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $n . ':a'
. ' -map ' . $voutLabel . ' -map ' . $n . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@@ -1894,7 +2248,7 @@ class VideoController extends Controller
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
. ' -vf ' . escapeshellarg($scale)
. ' -vf ' . escapeshellarg($scale . $assArg)
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
} else {
@@ -1902,6 +2256,7 @@ class VideoController extends Controller
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
}
@@ -1934,9 +2289,10 @@ class VideoController extends Controller
{
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$lyrics = request()->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
@@ -1955,7 +2311,7 @@ class VideoController extends Controller
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
return response()->json(['percent' => 100, 'status' => 'ready']);
@@ -2089,12 +2445,13 @@ class VideoController extends Controller
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '') . '.mp4';
. ($viz ? '-viz' : '')
. ($lyrics ? '-lyr' : '') . '.mp4';
}
/**
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Jobs;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Services\LlmLyricsService;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
* Original words are preserved verbatim; emojis are layered on top (in-line +
* trailing, multiple per line) per the admin's decoration prompt.
*
* Runs as its own job so a flaky LLM call can never delay or fail a successful
* transcription. Safe to re-run — already-decorated lines are skipped, so a
* second pass only fills in gaps.
*/
class DecorateLyricsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600;
public int $tries = 1;
/** Languages written without spaces between words (mirrors transcribe.py). */
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
public function __construct(public int $videoId, public ?int $trackId = null)
{
$this->onQueue('video-processing');
}
public function handle(LlmLyricsService $llm, NasSyncService $nas): void
{
// Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
// page) gates this job, and the LLM-service-level switch (AI/LLM page)
// gates the LLM call inside it. Either being OFF skips decoration.
if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
if (! $llm->decorateEnabled()) return;
$video = Video::find($this->videoId);
if (! $video) return;
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
if ($this->trackId && ! $track) return;
$data = $nas->getLyrics($video, $track);
if (! is_array($data) || empty($data['lines'])) return;
if (($data['status'] ?? null) !== 'ready') return;
// Decorate only lines that haven't been decorated yet — a re-run fills
// gaps cheaply instead of re-stamping the whole song.
$texts = [];
$indices = [];
foreach ($data['lines'] as $i => $ln) {
if (! empty($ln['decorated'])) continue;
$t = (string) ($ln['text'] ?? '');
if (trim($t) === '') continue;
$texts[] = $t;
$indices[] = $i;
}
if (! $texts) return;
try {
$decorated = $llm->decorateLines($texts);
} catch (\Throwable $e) {
Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
return;
}
if (! $decorated) return;
$applied = 0;
foreach ($decorated as $localIdx => $newText) {
if (! isset($indices[$localIdx])) continue;
$globalIdx = $indices[$localIdx];
$line = &$data['lines'][$globalIdx];
$line['text'] = $newText;
$line['decorated'] = true;
// The words array no longer matches the new (emoji-laced) text. We
// redistribute the existing [start,end] window evenly across the
// new tokens so the karaoke word-highlight still tracks the audio.
// Tokens that are pure emoji get the same per-slot timing as words.
$lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
$line['words'] = $this->redistributeWords(
(float) ($line['start'] ?? 0),
(float) ($line['end'] ?? 0),
$newText, $lang
);
unset($line);
$applied++;
}
if (! $applied) return;
$data['decorated_at'] = now()->toIso8601String();
$nas->putLyrics($video, $track, $data);
Log::info('DecorateLyricsJob: done', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'decorated' => $applied,
]);
}
/**
* Evenly distribute [start,end] across the line's tokens. Words for spaced
* languages, characters for spaceless scripts (Thai/CJK/…). Used after
* decoration so the karaoke word-highlight still tracks the audio.
*/
private function redistributeWords(float $start, float $end, string $text, string $lang): array
{
if ($text === '' || $end <= $start) return [];
$spaceless = in_array($lang, self::SPACELESS_LANGS, true);
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
$n = count($tokens ?: []);
if ($n === 0) return [];
$slot = ($end - $start) / $n;
$out = [];
foreach ($tokens as $i => $t) {
$out[] = [
'start' => round($start + $i * $slot, 3),
'end' => round($start + ($i + 1) * $slot, 3),
'text' => $t,
];
}
return $out;
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
/**
* Generate word-level synced lyrics for one audio track (the video's primary
* audio when $trackId is null, otherwise a specific extra-language track).
*
* Output is a per-track lyrics JSON written through NasSyncService::putLyrics()
* — source-of-truth, synced to NAS, never under cache/. Runs the GPU pipeline
* exactly once; playback just loads the file afterwards.
*/
class GenerateLyricsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 3600;
public int $tries = 1;
public function __construct(public int $videoId, public ?int $trackId = null)
{
$this->onQueue('video-processing');
}
/** Shared progress-file path (written by the pipeline, read by the status endpoint). */
public static function progressPath(int $videoId, ?int $trackId): string
{
return storage_path('app/tmp/lyrics_prog_' . $videoId . '_' . ($trackId ?? 'primary') . '.json');
}
/** Index of the GPU with the most free memory, or null if it can't be queried. */
private function freestGpu(): ?int
{
$out = []; $code = 1;
@exec('nvidia-smi --query-gpu=index,memory.free --format=csv,noheader,nounits 2>/dev/null', $out, $code);
if ($code !== 0 || empty($out)) return null;
$best = null; $bestFree = -1;
foreach ($out as $line) {
$parts = array_map('trim', explode(',', $line));
if (count($parts) < 2) continue;
$idx = (int) $parts[0]; $free = (int) $parts[1];
if ($free > $bestFree) { $bestFree = $free; $best = $idx; }
}
return $best;
}
public function handle(NasSyncService $nas): void
{
$video = Video::find($this->videoId);
if (! $video) return;
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
if ($this->trackId && ! $track) return;
$language = $track ? $track->language : $video->language;
// Mark as processing so the UI can show a generating state before the file lands.
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'processing',
'language' => $language,
]);
// Resolve a readable local copy of the audio (downloads from NAS if needed).
$audioPath = $track ? $nas->ensureLocalTrackCopy($track) : $nas->ensureLocalCopy($video);
$nasDownloaded = $audioPath && str_starts_with($audioPath, storage_path('app/nas_cache/'))
? $audioPath : null;
if (! $audioPath || ! file_exists($audioPath)) {
Log::error('GenerateLyricsJob: audio file unavailable', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
$nas->putLyrics($video, $track, [
'version' => 1, 'status' => 'failed', 'language' => $language,
'error' => 'audio file unavailable',
]);
return;
}
$python = base_path('ml/venv/bin/python');
$script = base_path('ml/transcribe.py');
$outTmp = storage_path('app/tmp/lyrics_' . $this->videoId . '_' . ($this->trackId ?? 'primary') . '.json');
$progress = self::progressPath($this->videoId, $this->trackId);
if (! is_dir(dirname($outTmp))) @mkdir(dirname($outTmp), 0775, true);
@file_put_contents($progress, json_encode(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']));
// Model/weight downloads land in a www-data-writable cache, not root's $HOME.
$cacheDir = base_path('ml/cache');
if (! is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
// NOTE: we deliberately do NOT force --language. The stored label is just
// metadata and is often wrong (e.g. a Tagalog song mislabeled "en"), which
// made WhisperX transcribe the wrong language. Auto-detecting from the
// isolated vocals is ground truth; the detected language is saved instead.
$args = [$python, $script, '--audio', $audioPath, '--out', $outTmp, '--progress', $progress];
// Pipeline feature toggles (admin → Lyrics Pipeline). Defaults preserve
// current behavior; admin can disable any sub-step that misbehaves.
$useDescription = Setting::get('lyrics_use_description', 'true') === 'true';
$vadEnabled = Setting::get('lyrics_vad_enabled', 'true') === 'true';
$vocalGapFill = Setting::get('lyrics_vocal_region_gapfill', 'true') === 'true';
$demucsEnabled = Setting::get('lyrics_demucs_enabled', 'false') === 'true';
if (! $vadEnabled) $args[] = '--no-vad';
if (! $vocalGapFill) $args[] = '--no-vocal-gapfill';
// If the song's description contains the lyrics (typed by the uploader),
// pass them to the pipeline so it ALIGNS those exact lines to the audio
// instead of generating noisier text from scratch. Only for the primary
// track — extra-language tracks have their own audio and aren't paired
// with the description text.
$userLyrFile = null;
if ($useDescription && ! $this->trackId && $video->description) {
// Prefer the deterministic regex parser. It strips emojis line-by-line
// without touching the underlying words, so it preserves every
// language a multilingual song contains (e.g. an English+Thai song
// keeps both halves). The LLM cleaner is only a backup for cases
// where the regex returns nothing — we've seen the LLM silently
// drop whole verses that happened to be wrapped in emoji decoration.
$descLines = \App\Support\LyricsDescriptionParser::extract($video->description);
$source = 'regex';
if (empty($descLines)) {
$llm = app(\App\Services\LlmLyricsService::class);
if ($llm->cleanLyricsEnabled()) {
try {
$descLines = $llm->cleanDescription($video->description);
$source = 'llm';
} catch (\Throwable $e) {
Log::warning('LLM clean failed: ' . $e->getMessage());
}
}
}
if ($descLines) {
$userLyrFile = storage_path('app/tmp/userlyr_' . $this->videoId . '.txt');
file_put_contents($userLyrFile, implode("\n", $descLines));
$args[] = '--user-lyrics';
$args[] = $userLyrFile;
// With description lyrics, Whisper is only providing word-timing
// anchors — its actual transcription text is discarded by the
// aligner. Vocal isolation (Demucs) helps transcription QUALITY
// but is unnecessary for timing, AND the Demucs→Whisper CUDA-
// context handoff has caused intermittent 50% futex deadlocks.
// So we skip Demucs in this mode by default; the admin can
// re-enable via the Lyrics Pipeline page.
$args[] = '--no-demucs';
Log::info('GenerateLyricsJob: using description lyrics', [
'video_id' => $this->videoId, 'lines' => count($descLines),
'source' => $source, 'demucs' => false,
'vad' => $vadEnabled, 'vocal_gapfill' => $vocalGapFill,
]);
}
}
// Honor the admin Demucs toggle for tracks WITHOUT description lyrics
// (where Whisper's transcription quality actually matters).
if (! $userLyrFile && ! $demucsEnabled) {
$args[] = '--no-demucs';
}
if (Setting::gpuUsable()) {
// Run on the GPU with the most free VRAM so a busy card never forces an
// out-of-memory fall back to slow CPU. With two cards this keeps every
// generation on the GPU and fast.
$args[] = '--gpu';
$args[] = (string) ($this->freestGpu() ?? Setting::gpuDevice());
}
Log::info('GenerateLyricsJob: starting', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'language' => $language, 'gpu' => Setting::gpuUsable(),
]);
try {
$result = Process::timeout($this->timeout)
->env([
'HOME' => $cacheDir,
'XDG_CACHE_HOME' => $cacheDir,
'HF_HOME' => $cacheDir . '/huggingface',
'TORCH_HOME' => $cacheDir . '/torch',
// Demucs runs as a subprocess BEFORE faster-whisper is imported.
// If OpenMP gets initialised in the parent before that fork, the
// post-fork CUDA/ctranslate2 stack can deadlock in futex_wait —
// we've seen this hang lyrics jobs at 50% indefinitely. Forcing
// single-threaded OpenMP in the parent eliminates the race
// (faster-whisper sets its own thread count internally anyway).
'OMP_NUM_THREADS' => '1',
'MKL_NUM_THREADS' => '1',
'OPENBLAS_NUM_THREADS' => '1',
])
->run($args);
if (! $result->successful() || ! file_exists($outTmp)) {
throw new \RuntimeException('transcribe.py failed: ' . substr($result->errorOutput(), -2000));
}
$data = json_decode((string) file_get_contents($outTmp), true);
if (! is_array($data) || empty($data['lines'])) {
throw new \RuntimeException('transcribe.py produced no lines');
}
$data['status'] = 'ready';
$data['generated_at'] = now()->toIso8601String();
$data['language'] = $data['language'] ?? $language;
$nas->putLyrics($video, $track, $data);
// Decoration is independent of the audio pipeline — kick it off as
// its own job so a flaky LLM call can't delay or fail a successful
// transcription. Skips itself silently if the decorator is off.
DecorateLyricsJob::dispatch($this->videoId, $this->trackId)
->onConnection('database');
Log::info('GenerateLyricsJob: done', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'lines' => count($data['lines']),
]);
} catch (\Throwable $e) {
Log::error('GenerateLyricsJob failed: ' . $e->getMessage(), [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
$nas->putLyrics($video, $track, [
'version' => 1, 'status' => 'failed', 'language' => $language,
'error' => $e->getMessage(),
]);
} finally {
@unlink($outTmp);
@unlink($progress);
if ($userLyrFile) @unlink($userLyrFile);
if ($nasDownloaded) @unlink($nasDownloaded);
}
}
}
+78 -1
View File
@@ -17,10 +17,12 @@ class Playlist extends Model
'visibility',
'is_default',
'share_token',
'view_count',
];
protected $casts = [
'is_default' => 'boolean',
'view_count' => 'integer',
];
// Relationships
@@ -83,12 +85,87 @@ class Playlist extends Model
return "{$minutes}m";
}
// Get total views of all videos in playlist
// Total of every viewer-session aggregated across the playlist's videos.
// Kept for the analytics-style "video time watched" metric — for the
// playlist's OWN view counter (cards, share link), use $playlist->view_count
// which is incremented per-device by bumpViewIfNew().
public function getTotalViewsAttribute()
{
return $this->videos()->get()->sum('view_count');
}
/**
* Record a playlist view if this viewer hasn't already counted within the
* dedup window. Mirrors the video_views pattern: signed-in users dedup by
* user_id, guests dedup by device fingerprint (preferred) or device cookie.
*
* Cheap and idempotent — runs as one EXISTS + (optionally) one INSERT +
* one atomic increment, all on indexed columns. Called via
* dispatchAfterResponse() so it never adds latency to the page render.
*/
public function bumpViewIfNew(\Illuminate\Http\Request $request): void
{
$userId = \Illuminate\Support\Facades\Auth::id();
$did = $request->cookie('_did');
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
// No identifier at all? Skip silently — a unidentifiable request would
// count on every refresh and inflate the counter.
if (! $userId && ! $did && ! $fp) return;
$q = \DB::table('playlist_views')
->where('playlist_id', $this->id)
->where('viewed_at', '>', now()->subHour());
if ($userId) {
$q->where('user_id', $userId);
} else {
$q->whereNull('user_id')->where(function ($w) use ($fp, $did) {
if ($fp) $w->orWhere('device_hash', $fp);
if ($did) $w->orWhere('device_id', $did);
});
}
if ($q->exists()) return;
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\DB::table('playlist_views')->insert([
'playlist_id' => $this->id,
'user_id' => $userId,
'device_id' => $did,
'device_hash' => $fp,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'viewed_at' => now(),
]);
\DB::table('playlists')->where('id', $this->id)->increment('view_count');
}
/**
* Compute previous/next videos from an already-loaded ordered collection
* (the playlist's videos in position order). Saves the 4+ extra queries
* that getNextVideo() / getPreviousVideo() would each fire.
*/
public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array
{
$idx = $orderedVideos->search(fn ($v) => $v->id === $current->id);
if ($idx === false) {
return [null, $orderedVideos->first()];
}
return [
$idx > 0 ? $orderedVideos[$idx - 1] : null,
$idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null,
];
}
// Check if user owns this playlist
public function isOwnedBy($user)
{
+34
View File
@@ -75,6 +75,40 @@ class Video extends Model
return $this->slides()->count() > 1;
}
/**
* Resolve the slide list for a given audio track, applying the sharing rule:
* 1. Slides explicitly owned by this track (audio_track_id = $trackId).
* 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy).
* 3. Slides owned by any other track (first track in id order).
* 4. Empty (caller falls back to cover image).
*
* Pass `null` for the primary audio (the one stored on the videos row).
*
* @return \Illuminate\Support\Collection<int,\App\Models\VideoSlide>
*/
public function slidesForTrack(?int $audioTrackId)
{
$all = $this->slides; // eager-loaded collection of every slide
if ($audioTrackId !== null) {
$own = $all->where('audio_track_id', $audioTrackId)->values();
if ($own->isNotEmpty()) return $own;
}
// Primary / song-wide bucket (audio_track_id IS NULL).
$primary = $all->whereNull('audio_track_id')->values();
if ($primary->isNotEmpty()) return $primary;
// Borrow from the first track (by id) that has any.
$byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id');
if ($byTrack->isNotEmpty()) {
$firstTrackId = $byTrack->keys()->sort()->first();
return $byTrack[$firstTrackId]->values();
}
return collect();
}
// ── Local filesystem path helpers ─────────────────────────────────────────
/**
+6
View File
@@ -14,6 +14,12 @@ class VideoAudioTrack extends Model
return $this->belongsTo(Video::class);
}
/** Slides explicitly owned by this track. Use Video::slidesForTrack() for fallback resolution. */
public function slides()
{
return $this->hasMany(VideoSlide::class, 'audio_track_id')->orderBy('position');
}
public function getStreamUrlAttribute(): string
{
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);
+6 -1
View File
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model
{
protected $fillable = ['video_id', 'filename', 'position'];
protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
public function video()
{
return $this->belongsTo(Video::class);
}
public function audioTrack()
{
return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
}
public function getUrlAttribute(): string
{
return route('media.thumbnail', $this->filename);
+266
View File
@@ -0,0 +1,266 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Optional LLM helper for the lyrics pipeline.
*
* Supports multiple providers configured in Admin → Settings → AI / LLM
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
* Picks the provider flagged "Active" and dispatches the request through the
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
* the local model.
*/
class LlmLyricsService
{
public function isEnabled(): bool
{
return Setting::get('llm_enabled', 'false') === 'true'
&& $this->activeProvider() !== null;
}
public function cleanLyricsEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true';
}
public function decorateEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true';
}
/** The currently selected provider config, or null if none. */
public function activeProvider(): ?array
{
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
if (! $providers) return null;
$activeId = (string) Setting::get('llm_active_id', '');
foreach ($providers as $p) {
if (($p['id'] ?? null) === $activeId) {
$kind = $p['kind'] ?? 'ollama';
// An Ollama provider doesn't need a key; the others do.
if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null;
if (trim((string) ($p['model'] ?? '')) === '') return null;
return $p;
}
}
return null;
}
/** Returns clean lyric lines extracted by the LLM, or [] on any failure. */
public function cleanDescription(?string $description): array
{
if (! $description || ! $this->cleanLyricsEnabled()) return [];
$provider = $this->activeProvider();
// v2 cache key: the prompt was rewritten to stop dropping English/Thai
// lyric lines that happened to carry leading/trailing emoji decoration.
$cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description);
return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) {
$prompt = "Extract the SUNG lyric lines from this song description, preserving every\n"
. "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n"
. "and Thai, English and Italian, English and Arabic) — KEEP EVERY LANGUAGE.\n\n"
. "KEEP a line when it contains real lyric words, even if it's wrapped in or\n"
. " punctuated by emojis. Example: '🛡️💻 Met behind the firewalls 🌌' → KEEP.\n"
. " Strip ONLY the emojis themselves; the lyric words stay untouched.\n"
. "DROP a line ONLY when it is one of:\n"
. " • the song title or artist credit\n"
. " • a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n"
. " Pre-Chorus / Outro / Intro / 副歌 / 후렴 / كورس / ท่อน / etc.) — typically\n"
. " one or two words, possibly numbered\n"
. " • an instrument or production note inside 【…】 or 〔…〕 brackets\n"
. " • a row that is ONLY emojis / separators / decorative symbols with no words\n"
. " • commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n"
. "Hard rules:\n"
. " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n"
. " English to Thai). The output of each kept line must be in the SAME language\n"
. " and script as the original line.\n"
. " - DO NOT merge or split lines. One source lyric line → one output entry.\n"
. " - Preserve original punctuation (drop only the emojis).\n"
. " - Maintain the original order.\n\n"
. "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n"
. "DESCRIPTION:\n" . $description;
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$arr = json_decode($raw, true);
if (! is_array($arr)) return [];
$out = [];
foreach ($arr as $line) {
$line = trim((string) $line);
if ($line === '') continue;
$out[] = $line;
}
return $out;
});
}
/**
* Rewrite each lyric line with heavy, expressive emoji styling. Emojis go
* inside the line AND at the end; multiple per line where it fits. The
* original words are NEVER changed — emojis are layered on top.
*
* Returns [index => decoratedLineText]. The caller swaps line.text and
* re-distributes the word timings across the new tokens.
*/
public function decorateLines(array $lines): array
{
if (! $lines || ! $this->decorateEnabled()) return [];
$provider = $this->activeProvider();
$cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines));
return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) {
$numbered = [];
foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l;
$prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n"
. "Strict instructions:\n"
. "- Add emojis to almost every line (rich and visually striking, not minimal).\n"
. "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n"
. "- Use 2–4 emojis per line on average, more on emotional peaks.\n"
. "- Match emojis to the line's specific emotion, action, image, or vibe.\n"
. "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n"
. " • Aim for 30+ distinct emojis across the song.\n"
. " • Never reuse the same emoji on two adjacent lines.\n"
. " • Do NOT lean on the same 5–6 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
. " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
. " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
. "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
. " re-spelling, no script conversion. Preserve every original word verbatim.\n"
. "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
. " designed lyric post or viral TikTok caption.\n"
. "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
. " must map to exactly one output line, in the same order, with the same words.\n\n"
. "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
. "decorated line text. No prose, no markdown, no code fence.\n\n"
. "LINES:\n" . implode("\n", $numbered);
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = [];
foreach ($obj as $k => $v) {
if (! is_string($v)) continue;
$v = trim($v);
if ($v === '') continue;
$idx = ((int) $k) - 1;
if ($idx < 0 || ! isset($lines[$idx])) continue;
// Cheap safety check: the original words must survive verbatim
// (the LLM should only LAYER emojis on top). Drop the
// decoration if too many original characters are missing.
if (! self::preservesOriginal($lines[$idx], $v)) continue;
$out[$idx] = $v;
}
return $out;
});
}
/**
* Verify the decorated line still contains every alphanumeric character of
* the original (in the same order). Stops the LLM from quietly rewording
* a line — we keep only decorations that strictly add emojis on top.
*/
private static function preservesOriginal(string $orig, string $decorated): bool
{
$strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $strip($orig);
$b = $strip($decorated);
if ($a === '') return true;
// Sequential subsequence check: every char of $a must appear in $b in order.
$aLen = mb_strlen($a); $bLen = mb_strlen($b);
$j = 0;
for ($i = 0; $i < $aLen; $i++) {
$needle = mb_substr($a, $i, 1);
$found = false;
for (; $j < $bLen; $j++) {
if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
}
if (! $found) return false;
}
return true;
}
/** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string
{
$p = $this->activeProvider();
if (! $p) return '';
try {
return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
};
} catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
return '';
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
]);
if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['message']['content'] ?? '');
}
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([
'x-api-key' => (string) $p['api_key'],
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
if (! $resp->successful()) {
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
->acceptJson()
->post($endpoint . '/v1/chat/completions', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['choices'][0]['message']['content'] ?? '');
}
}
+261 -55
View File
@@ -67,18 +67,69 @@ class NasSyncService
return $slug ?: 'video';
}
/**
* Top-level folder under users/{slug}/ for a video of the given type.
* Frozen at upload time — see CLAUDE.md (canonical storage layout).
*/
public function typeFolder(Video $video): string
{
return match ($video->type) {
'music' => 'music',
'match' => 'sports',
default => 'videos', // generic + any unknown
};
}
/**
* Track folder name inside a music song folder. Format: {lang}-{db-id}.
* For the primary audio (no VideoAudioTrack row), pass null — the videos
* row id is used as the track id and the video's primary language as the
* language. Always lowercase. Falls back to 'xx' when no language is set.
*/
public function trackFolderName(Video $video, ?VideoAudioTrack $track = null): string
{
$lang = mb_strtolower(trim((string) ($track ? $track->language : $video->language)));
if ($lang === '') $lang = 'xx';
$id = $track ? $track->id : $video->id;
return "{$lang}-{$id}";
}
/**
* Absolute NAS-relative path to a music track's folder. Music only.
* Caller must ensure $video->type === 'music'.
*/
public function trackDir(Video $video, ?VideoAudioTrack $track = null): string
{
return $this->resolveVideoDir($video) . '/tracks/' . $this->trackFolderName($video, $track);
}
/**
* Return the NAS directory for a video.
*
* On first sync → pick a conflict-free slug and create the folder.
* On subsequent → find the existing folder by matching video ID in meta.json
* so renames of the title never lose the folder.
*
* Type-segregated: music → music/, sports → sports/, generic → videos/.
* The folder is frozen at first resolution — if the video's path already
* points somewhere, that location wins (so type edits don't move files).
*/
public function resolveVideoDir(Video $video): string
{
// Already organised — trust the stored path verbatim. This keeps legacy
// flat layouts working and prevents type edits from relocating files.
if (str_starts_with((string) $video->path, 'users/')) {
// Walk up past any tracks/{lang-id}/ then file-name segments.
$segs = explode('/', $video->path);
// Expect users/{slug}/{type-folder}/{video-slug}/...
if (count($segs) >= 4) {
return implode('/', array_slice($segs, 0, 4));
}
}
$video->loadMissing('user');
$userSlug = $this->userSlug($video->user);
$base = "users/{$userSlug}/videos";
$base = "users/{$userSlug}/" . $this->typeFolder($video);
$titleSlug = $this->titleSlug($video->title);
// 1. Try the current title slug and numbered variants (-2, -3 …)
@@ -156,20 +207,22 @@ class NasSyncService
*/
public function localVideoDir(Video $video): string
{
// Already organised — derive from path
if (str_starts_with($video->path, 'users/')) {
$dir = dirname(storage_path('app/' . $video->path));
// If the primary file lives inside a 'tracks/' subfolder (promoted track),
// go up one extra level to reach the video root directory.
if (basename($dir) === 'tracks') {
$dir = dirname($dir);
// Already organised — derive from path (respects whichever type folder it
// ended up in, even if the type has since been edited).
if (str_starts_with((string) $video->path, 'users/')) {
$segs = explode('/', $video->path);
if (count($segs) >= 4) {
return storage_path('app/' . implode('/', array_slice($segs, 0, 4)));
}
// Defensive fallback for malformed legacy paths
$dir = dirname(storage_path('app/' . $video->path));
if (basename($dir) === 'tracks') $dir = dirname($dir);
return $dir;
}
$video->loadMissing('user');
$userSlug = $this->userSlug($video->user);
$base = storage_path("app/users/{$userSlug}/videos");
$base = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
$titleSlug = $this->titleSlug($video->title);
for ($i = 1; $i <= 50; $i++) {
@@ -206,60 +259,68 @@ class NasSyncService
$video->loadMissing(['user', 'slides']);
$dir = $this->localVideoDir($video);
$fileSlug = $this->titleSlug($video->title);
$videoDir = $this->localVideoDir($video); // users/{slug}/{type-folder}/{slug}
$isMusic = ($video->type === 'music');
// Music wraps the primary inside tracks/{lang-id}/; others stay flat.
$primaryDir = $isMusic
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
: $videoDir;
@mkdir($dir, 0755, true);
@mkdir($primaryDir, 0755, true);
$userSlug = $this->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($dir);
$updates = [];
$userSlug = $this->userSlug($video->user);
$videoRel = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
$primaryRel = $isMusic
? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
: $videoRel;
$updates = [];
// ── Video file ───────────────────────────────────────────────────
// ── Video / primary audio file ───────────────────────────────────
$oldVideoPath = storage_path('app/' . $video->path);
if (file_exists($oldVideoPath)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
$newFileName = "{$fileSlug}.{$ext}";
rename($oldVideoPath, "{$dir}/{$newFileName}");
$updates['path'] = "{$relDir}/{$newFileName}";
$updates['filename'] = $newFileName;
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
rename($oldVideoPath, "{$primaryDir}/{$canonical}");
$updates['path'] = "{$primaryRel}/{$canonical}";
$updates['filename'] = $canonical;
}
// ── Slides (process first; for audio, thumbnail IS slide 0) ──────
// ── Slides — music primary track only ────────────────────────────
$firstSlideRelPath = null;
if ($video->slides->isNotEmpty()) {
@mkdir("{$dir}/slides", 0755, true);
if ($isMusic && $video->slides->isNotEmpty()) {
@mkdir("{$primaryDir}/slides", 0755, true);
foreach ($video->slides->sortBy('position') as $slide) {
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
if (! file_exists($oldSlidePath)) continue;
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$newSlideName = "{$slide->id}.{$ext}";
rename($oldSlidePath, "{$dir}/slides/{$newSlideName}");
$newSlideFilename = "{$relDir}/slides/{$newSlideName}";
$newSlideName = "{$slide->position}.{$ext}";
rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
$newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
$slide->update(['filename' => $newSlideFilename]);
if ($firstSlideRelPath === null) {
$firstSlideRelPath = $newSlideFilename;
}
}
// For audio uploads the thumbnail is the first slide
if ($firstSlideRelPath !== null) {
$updates['thumbnail'] = $firstSlideRelPath;
}
}
// ── Standalone thumbnail (video uploads, no slides) ──────────────
// ── Standalone thumbnail (sports/generic + music-without-slides) ─
if ($video->thumbnail && ! isset($updates['thumbnail'])) {
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($oldThumbPath)) {
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
$newThumbName = "thumb.{$ext}";
rename($oldThumbPath, "{$dir}/{$newThumbName}");
$updates['thumbnail'] = "{$relDir}/{$newThumbName}";
$thumbDirAbs = $isMusic ? $primaryDir : $videoDir;
$thumbRelDir = $isMusic ? $primaryRel : $videoRel;
rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
$updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
}
}
// ── meta.json (enables localVideoDir to identify this dir later) ─
$this->writeLocalMeta($video, $dir);
// ── meta.json (lives at the video / song root, not per-track) ────
$this->writeLocalMeta($video, $videoDir);
if (! empty($updates)) {
$video->update($updates);
@@ -603,41 +664,46 @@ class NasSyncService
): void {
$video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video
$fileSlug = $this->titleSlug($video->title);
$this->mkdirp($dir);
$videoDir = $this->resolveVideoDir($video); // users/{slug}/{type-folder}/{slug}
$isMusic = ($video->type === 'music');
// Music uses per-track folders. Primary audio + its slides + lyrics live in
// tracks/{lang-id}/. Sports + generic stay flat: video.{ext} at the root.
$primaryDir = $isMusic
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
: $videoDir;
$this->mkdirp($primaryDir);
$updates = [];
// ── Video file ───────────────────────────────────────────────────
// ── Video / primary audio file ───────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) {
$ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
// New canonical name. Music: audio.{ext}. Sports/generic: video.{ext}.
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
$nasFile = "{$primaryDir}/{$canonical}";
$ok = $this->putFile($tempVideoPath, $nasFile);
if (! $ok) {
// Leave the local temp file intact so the caller can fall back to local storage
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$fileSlug}.{$ext}");
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
}
@unlink($tempVideoPath);
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
$updates['filename'] = "{$fileSlug}.{$ext}";
$updates['path'] = $nasFile;
$updates['filename'] = $canonical;
}
// ── Slides (audio uploads — thumbnail IS the first slide) ────────
// ── Slides — music primary track only ────────────────────────────
$firstSlideNasPath = null;
if (! empty($slideAbsPaths)) {
$this->mkdirp("{$dir}/slides");
if ($isMusic && ! empty($slideAbsPaths)) {
$this->mkdirp("{$primaryDir}/slides");
foreach ($video->slides->sortBy('position') as $slide) {
$absPath = $slideAbsPaths[$slide->position] ?? null;
if (! $absPath || ! file_exists($absPath)) continue;
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
$nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}";
$nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
if ($this->putFile($absPath, $nasSlideFile)) {
@unlink($absPath);
$slideRelPath = $nasSlideFile;
$slide->update(['filename' => $slideRelPath]);
$slide->update(['filename' => $nasSlideFile]);
if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath;
$firstSlideNasPath = $nasSlideFile;
}
}
}
@@ -646,26 +712,31 @@ class NasSyncService
}
}
// ── Standalone thumbnail (video uploads without slides) ──────────
// ── Standalone thumbnail (sports/generic + music-without-slides) ──
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
if ($this->putFile($tempThumbPath, "{$dir}/thumb.webp")) {
// Music thumb lives next to the primary audio inside the track folder.
$thumbDir = $isMusic ? $primaryDir : $videoDir;
$thumbExt = pathinfo($tempThumbPath, PATHINFO_EXTENSION) ?: 'webp';
$nasThumb = "{$thumbDir}/thumb.{$thumbExt}";
if ($this->putFile($tempThumbPath, $nasThumb)) {
@unlink($tempThumbPath);
$updates['thumbnail'] = "{$dir}/thumb.webp";
$updates['thumbnail'] = $nasThumb;
}
}
// ── meta.json ────────────────────────────────────────────────────
// ── meta.json (song / video root level, not per-track) ───────────
$this->putContent(json_encode([
'id' => $video->id,
'user_id' => $video->user_id,
'title' => $video->title,
'type' => $video->type,
'created_at' => $video->created_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$videoDir}/meta.json");
// File is now on NAS and accessible — mark as ready
$updates['status'] = 'ready';
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $dir]);
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);
$video->update($updates);
}
@@ -705,6 +776,17 @@ class NasSyncService
}
}
// lyrics/*.json (source-of-truth; push any local mirror that exists)
$video->loadMissing('audioTracks');
$lyricsTargets = array_merge([null], $video->audioTracks->all());
foreach ($lyricsTargets as $lt) {
$localLyrics = $this->lyricsLocalPath($video, $lt);
if (is_file($localLyrics)) {
$this->mkdirp("{$dir}/lyrics");
$this->putFile($localLyrics, $this->lyricsNasPath($video, $lt));
}
}
// meta.json (always written last so readMeta can find the folder)
$this->putContent(json_encode([
'id' => $video->id,
@@ -994,6 +1076,130 @@ class NasSyncService
return $content !== false ? $content : null;
}
// ── Lyrics (per-track, source-of-truth JSON synced to NAS) ─────────────────
public function lyricsDir(Video $video): string
{
// Legacy shared lyrics/ folder location — kept for read-fallback only.
// Derived from the stored path (no NAS lookup) — see callers for context.
if (str_starts_with((string) $video->path, 'users/')) {
$dir = dirname($video->path);
if (basename($dir) === 'tracks') $dir = dirname($dir);
return $dir . '/lyrics';
}
return $this->resolveVideoDir($video) . '/lyrics';
}
/**
* NAS-relative path for a track's lyrics under the new per-track-folder layout:
* tracks/{lang-id}/lyrics.json
* Falls back to the legacy shared lyrics/ folder if the song still uses the
* old flat layout (we detect this by checking whether the path passes through
* a tracks/ segment).
*/
public function lyricsNasPath(Video $video, ?VideoAudioTrack $track = null): string
{
// New layout (music with per-track folders).
if ($video->type === 'music' && str_starts_with((string) $video->path, 'users/')) {
$segs = explode('/', $video->path);
// users/{slug}/music/{song-slug}/tracks/{lang-id}/audio.{ext}
if (count($segs) >= 6 && $segs[4] === 'tracks') {
$songRoot = implode('/', array_slice($segs, 0, 4));
$folder = $this->trackFolderName($video, $track);
return "{$songRoot}/tracks/{$folder}/lyrics.json";
}
}
// Legacy fallback — shared lyrics/ folder, keyed by track suffix.
$name = $track ? "track-{$track->id}" : 'primary';
return $this->lyricsDir($video) . "/{$name}.json";
}
/** Canonical local mirror path for a track's lyrics. */
public function lyricsLocalPath(Video $video, ?VideoAudioTrack $track = null): string
{
return storage_path('app/' . $this->lyricsNasPath($video, $track));
}
/**
* Write lyrics JSON for a track. Always writes the local mirror; pushes to
* NAS when reachable. Returns false only if the NAS push was attempted and
* failed (the local copy is written regardless, so nas:auto-sync can retry).
*/
public function putLyrics(Video $video, ?VideoAudioTrack $track, array $data): bool
{
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
$local = $this->lyricsLocalPath($video, $track);
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
file_put_contents($local, $json);
if ($this->isEnabled()) {
// Make sure the directory containing this lyrics file exists on NAS.
// New layout: tracks/{lang-id}/. Legacy: shared lyrics/ folder.
$this->mkdirp(dirname($this->lyricsNasPath($video, $track)));
return $this->putContent($json, $this->lyricsNasPath($video, $track));
}
return true;
}
/**
* Read lyrics JSON from the LOCAL mirror only — never touches the NAS.
* Use this on hot paths (page render, player-data) so a missing file can't
* block the request on smbclient or the port-445 reachability probe. The
* lyrics mirror is written by putLyrics() and is never wiped by cache
* cleanup, so a song that has lyrics will have them locally.
*/
public function getLocalLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
{
$local = $this->lyricsLocalPath($video, $track);
if (! is_file($local)) {
// Read fallback to the legacy shared lyrics/ folder so songs that
// haven't been migrated yet still serve their lyrics.
$legacy = storage_path('app/' . $this->lyricsDir($video) . '/' . ($track ? "track-{$track->id}" : 'primary') . '.json');
if (is_file($legacy)) $local = $legacy;
}
if (is_file($local)) {
$d = json_decode((string) file_get_contents($local), true);
if (is_array($d)) return $d;
}
return null;
}
/**
* Remove a track's lyrics from both the local mirror and the NAS. Used by
* the owner when the generated lyrics are wrong and they want to start
* over — after calling this, the next Generate produces a fresh file.
*/
public function deleteLyrics(Video $video, ?VideoAudioTrack $track = null): void
{
$local = $this->lyricsLocalPath($video, $track);
if (is_file($local)) @unlink($local);
if ($this->isEnabled()) {
try { $this->deleteFile($this->lyricsNasPath($video, $track)); }
catch (\Throwable $e) { /* best-effort: local removal is what matters for next regenerate */ }
}
}
/** Read lyrics JSON for a track, pulling from NAS into the local mirror if needed. */
public function getLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
{
$local = $this->lyricsLocalPath($video, $track);
if (is_file($local)) {
$d = json_decode((string) file_get_contents($local), true);
if (is_array($d)) return $d;
}
if ($this->isEnabled()) {
$c = $this->getContent($this->lyricsNasPath($video, $track));
if ($c !== null) {
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
file_put_contents($local, $c);
$d = json_decode($c, true);
if (is_array($d)) return $d;
}
}
return null;
}
public function deleteFile(string $nasRelPath): void
{
$cfg = $this->cfg();
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Support;
/**
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
* karaoke timing from a lyrics JSON payload (the shape produced by
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
*
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
* time it waits before the highlight reaches it, so the cumulative \k before a
* word equals its onset relative to the line start. PrimaryColour is the sung
* (filled) colour, SecondaryColour the not-yet-sung colour.
*/
class LyricsAss
{
/** Render at the same canvas the slideshow uses. */
private const W = 1280;
private const H = 720;
/**
* Write an .ass file for the given lyrics data. Returns true on success,
* false when there are no usable timed lines (caller should skip burning).
*/
public static function write(array $lyrics, string $outPath): bool
{
$lines = $lyrics['lines'] ?? [];
if (! is_array($lines) || ! $lines) return false;
$events = [];
foreach ($lines as $ln) {
$start = $ln['start'] ?? null;
$end = $ln['end'] ?? null;
if ($start === null || $end === null) continue;
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
$text = $words
? self::karaokeText($words, (float) $start)
: self::escape((string) ($ln['text'] ?? ''));
if ($text === '') continue;
// Hold the line a touch past its last word for readability.
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
. ',Lyrics,,0,0,,' . $text;
}
if (! $events) return false;
$ass = self::header() . implode("\n", $events) . "\n";
return file_put_contents($outPath, $ass) !== false;
}
private static function karaokeText(array $words, float $lineStart): string
{
$out = '';
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
if ($lead > 0) $out .= '{\k' . $lead . '}';
$n = count($words);
foreach ($words as $i => $w) {
$wStart = (float) $w['start'];
$wEnd = (float) $w['end'];
$dur = max(1, (int) round(($wEnd - $wStart) * 100));
$out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
if ($i < $n - 1) {
$gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
$out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
}
}
return $out;
}
/** ASS timestamp: H:MM:SS.cc (centiseconds). */
private static function ts(float $t): string
{
if ($t < 0) $t = 0;
$cs = (int) round($t * 100);
$h = intdiv($cs, 360000);
$m = intdiv($cs % 360000, 6000);
$s = intdiv($cs % 6000, 100);
$c = $cs % 100;
return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
}
private static function escape(string $s): string
{
// Strip ASS override delimiters and collapse newlines.
$s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
return str_replace(["\r\n", "\n", "\r"], ' ', $s);
}
private static function header(): string
{
// Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
// (translucent grey), heavy outline + shadow for legibility over artwork.
return "[Script Info]\n"
. "ScriptType: v4.00+\n"
. 'PlayResX: ' . self::W . "\n"
. 'PlayResY: ' . self::H . "\n"
. "WrapStyle: 0\n"
. "ScaledBorderAndShadow: yes\n\n"
. "[V4+ Styles]\n"
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
. "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
. "[Events]\n"
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Support;
/**
* Extract clean lyric lines from a video's free-text description.
*
* Many users paste the song's lyrics into the description with section markers,
* emoji decorations, instrument tags, etc. When that's present we want to USE
* those exact lines (and let the pipeline only do the *sync*, not the
* transcription), because they're far more accurate than anything Whisper can
* derive from sung audio.
*
* Returns an empty array when no usable lyric block is found.
*/
class LyricsDescriptionParser
{
/** Heuristic threshold: descriptions with fewer cleaned lines aren't worth aligning. */
private const MIN_LYRIC_LINES = 4;
public static function extract(?string $desc): array
{
if (! $desc) return [];
// HTML descriptions use <br> for line breaks — convert those (and other
// block-ending tags) into real newlines BEFORE stripping tags, otherwise
// the entire body collapses into one long run-on line.
// Heading tags (<h1>…<h6>) carry the song title — drop their content
// entirely so the title never leaks into the lyric list.
$text = preg_replace('/<\s*h[1-6][^>]*>.*?<\s*\/\s*h[1-6]\s*>/isu', "\n", $desc);
$text = preg_replace('/<\s*br\s*\/?>/i', "\n", $text);
$text = preg_replace('/<\s*\/\s*(p|div|li|tr|blockquote)\s*>/i', "\n", $text);
$text = strip_tags($text);
// Decode HTML entities (&nbsp;, &amp;, etc.) so the comparison later isn't fooled.
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\r\n|\r/u', "\n", $text);
// First pass: clean each line and flag section headers (Verse / Ritornello
// / Bridge / etc.) so they can be dropped — those aren't sung.
$cleaned = [];
foreach (explode("\n", $text) as $line) {
$line = self::cleanLine($line);
if ($line === null) continue;
$cleaned[] = ['text' => $line, 'header' => self::isSectionHeader($line)];
}
// Title detection: if the first non-header line is immediately followed by
// a section header (e.g. "Figlio Mio — Viaggio di Vita" then "Verso 1"),
// that first line is the song title — drop it too.
$firstIdx = null;
foreach ($cleaned as $i => $c) {
if (! $c['header']) { $firstIdx = $i; break; }
}
$dropTitle = false;
if ($firstIdx !== null) {
for ($j = $firstIdx + 1; $j < count($cleaned); $j++) {
if ($cleaned[$j]['header']) { $dropTitle = true; break; }
break; // first thing after is a real lyric line → not a title block
}
}
$out = [];
foreach ($cleaned as $i => $c) {
if ($c['header']) continue;
if ($dropTitle && $i === $firstIdx) continue;
$out[] = $c['text'];
}
// Avoid mistakenly aligning non-lyric descriptions (a credit line, a URL,
// etc.). Require at least a handful of plausible lyric lines.
if (count($out) < self::MIN_LYRIC_LINES) return [];
return $out;
}
/**
* True when a line is a section marker (Verse / Chorus / Bridge / Outro /
* their many translations) rather than a sung lyric. Matches the WHOLE line
* so a real lyric containing one of these words isn't mistakenly dropped.
*/
private static function isSectionHeader(string $line): bool
{
$t = mb_strtolower(trim($line));
if ($t === '') return false;
$roots = [
'intro', 'outro', 'interlude', 'instrumental',
'verse', 'verso', 'verset', 'couplet', 'estrofa', 'strofa',
'chorus', 'ritornello', 'refrain', 'refrão', 'refrao', 'coro', 'estribillo',
'pre[\s\-]?chorus', 'pre[\s\-]?ritornello', 'pre[\s\-]?coro',
'pre[\s\-]?refrain', 'pre[\s\-]?refrão', 'pré[\s\-]?refrain',
'bridge', 'ponte', 'puente', 'pont', 'brücke', 'brucke',
'hook', 'drop', 'breakdown', 'tag', 'vamp', 'coda', 'reprise',
// CJK / Thai / Arabic / Korean
'サビ', 'コーラス', 'バース', 'ブリッジ', 'イントロ', 'アウトロ', 'フック', '間奏',
'ท่อน', 'คอรัส', 'ฮุก', 'บริดจ์', 'อินโทร', 'เอาท์โทร',
'前奏', '副歌', '桥段', '主歌', '尾奏',
'كورس', 'بريدج', 'كوبليه',
'후렴', '브릿지', '인트로', '아웃트로', '훅',
];
$rootRe = implode('|', $roots);
// Optional trailing number, "final/finale/reprise", roman numerals.
$pattern = '/^(?:' . $rootRe . ')[\s\d:\-—\.]*(?:final|finale|reprise|ii|iii|iv|v|vi|2|3|4|5)?\s*$/iu';
return (bool) preg_match($pattern, $t);
}
/** Returns the cleaned line, or null if it should be discarded. */
private static function cleanLine(string $line): ?string
{
$line = trim($line);
if ($line === '') return null;
// Strip markdown emphasis (* _ ~) and leading list bullets / quote markers.
$line = preg_replace('/^[\s>\-\*•♪♫·]+/u', '', $line);
$line = preg_replace('/[\*_~`]+/u', '', $line);
// Drop instrument / section annotations inside Japanese-style brackets:
// 【 箏・尺八・篠笛・優しい歌声 】 — these aren't lyrics.
$line = preg_replace('/【[^】]*】/u', '', $line);
$line = preg_replace('/〔[^〕]*〕/u', '', $line);
$line = preg_replace('/\[\[[^\]]*\]\]/u', '', $line);
// Strip emoji / pictographic symbols and the invisible glue that often
// sticks to them (variation selectors, ZWJ) so nothing leaves behind a
// bare diacritic when the visible emoji is removed.
$line = preg_replace(
'/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{0F3A}-\x{0F3D}\x{FE00}-\x{FE0F}\x{200B}-\x{200F}\x{2060}]/u',
'', $line
);
// Collapse internal whitespace.
$line = preg_replace('/\s+/u', ' ', $line);
$line = trim($line);
if ($line === '') return null;
// Must contain at least one letter (Unicode), and at least 3 characters
// after stripping — discards "🌸 平穏 🌸" (header) and "──" separators.
if (! preg_match('/\p{L}/u', $line)) return null;
if (mb_strlen($line) < 3) return null;
return $line;
}
}