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

+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)