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

+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';
}
/**