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:
1 parent
73527f3781
commit
f98e5415a3
60 files changed
+7920
-2675
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user