ghassan f98e5415a3 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 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

414 lines
19 KiB
PHP

@extends('admin.layout')
@section('title', 'AI / LLM Settings')
@section('page_title', 'AI / LLM Settings')
@section('extra_styles')
@include('admin.partials.settings-styles')
<style>
.llm-providers { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.llm-prov-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
}
.llm-prov-grid {
display: grid;
grid-template-columns: 80px 1.1fr 1.3fr 1.6fr 1.4fr 1.6fr auto auto;
gap: 8px; align-items: center;
}
.llm-prov-grid .adm-input-full { height: 34px; font-size: 12px; padding: 0 10px; }
.llm-prov-grid select.adm-input-full { cursor: pointer; }
.llm-prov-grid select.adm-input-full option { background: #1e1e1e; }
.llm-prov-grid .llm-active {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-2);
cursor: pointer; user-select: none;
}
.llm-prov-grid .llm-active input[type="radio"] { accent-color: var(--brand); }
.llm-prov-grid .llm-active input[type="radio"]:checked + span { color: var(--brand); font-weight: 700; }
.llm-prov-grid .llm-remove,
.llm-prov-grid .llm-test {
height: 34px; padding: 0 12px;
display: inline-flex; align-items: center; justify-content: center;
white-space: nowrap; gap: 6px; font-size: 12px;
}
.llm-prov-grid .llm-remove { width: 36px; padding: 0; }
.llm-prov-status {
margin-top: 10px; font-size: 12px;
display: none; align-items: center; gap: 8px; flex-wrap: wrap;
}
.llm-prov-status[data-state="info"] { display: flex; color: var(--text-2); }
.llm-prov-status[data-state="success"] { display: flex; color: #4ade80; }
.llm-prov-status[data-state="error"] { display: flex; color: #f87171; }
.llm-model-chips {
display: none; flex-wrap: wrap; gap: 6px;
margin-top: 10px; padding-top: 10px;
border-top: 1px dashed var(--border);
max-height: 180px; overflow-y: auto;
}
.llm-model-chips.show { display: flex; }
.llm-model-chip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px; font-size: 12px;
color: var(--text); cursor: pointer;
transition: border-color .15s, background .15s, color .15s;
}
.llm-model-chip:hover { border-color: var(--brand); }
.llm-model-chip.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
@media (max-width: 1200px) {
.llm-prov-grid { grid-template-columns: 80px 1fr 1fr 1fr 1fr 1fr auto auto; gap: 6px; }
.llm-prov-grid .adm-input-full { font-size: 11px; }
}
@media (max-width: 768px) {
.llm-prov-grid { grid-template-columns: 1fr 1fr; }
.llm-prov-grid .llm-active { grid-column: 1 / -1; }
.llm-prov-grid .llm-test,
.llm-prov-grid .llm-remove { justify-self: end; }
}
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-stars"></i> AI / LLM Settings</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="llmForm">
@csrf
<input type="hidden" name="llm_section" value="1">
{{-- ── AI / LLM toggles ──────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-stars"></i>
Lyrics LLM Pipeline
@if($settings['llm_enabled'] === 'true')
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
@else
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
@endif
</div>
<div class="settings-section-body">
<p style="margin:0 0 18px;font-size:13px;color:var(--text-2);line-height:1.5;">
Configure one or more LLM providers (local Ollama or hosted APIs). Pick one
as <strong>Active</strong> that's the provider the lyrics pipeline uses
to clean descriptions and (optionally) pick per-line emojis. With LLM off,
the pipeline uses the built-in regex parser and keyword emoji map.
</p>
<div class="setting-row">
<div class="setting-label">
<strong>Enable LLM</strong>
<small>Master switch. When off, the regex/keyword pipeline runs instead.</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="llm_enabled" value="true" {{ $settings['llm_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Use the active LLM</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Clean description lyrics</strong>
<small>Use the active LLM to drop titles, section headers, instrument tags, etc. from a song's description before aligning to audio.</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="llm_clean_lyrics" value="true" {{ $settings['llm_clean_lyrics'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Clean before alignment</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Decorate lines with emojis</strong>
<small>Replaces the built-in keyword emoji with a per-line contextual emoji chosen by the LLM. Results are cached.</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="llm_decorate_lyrics" value="true" {{ $settings['llm_decorate_lyrics'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Per-line emojis</span>
</label>
</div>
</div>
</div>
</div>
{{-- ── Providers ─────────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-plug"></i>
Providers
</div>
<div class="settings-section-body">
<p style="margin:0 0 14px;font-size:13px;color:var(--text-2);line-height:1.5;">
Add as many as you like. <strong>Ollama</strong> runs locally with no API key
(default <code>http://localhost:11434</code>). <strong>Anthropic</strong> /
<strong>OpenAI</strong> need a key. Pick the radio next to the provider you
want active.
</p>
<div id="llmProvidersList" class="llm-providers">
@foreach($settings['llm_providers'] as $i => $p)
@php
$pid = $p['id'] ?? \Illuminate\Support\Str::uuid()->toString();
$kind = $p['kind'] ?? 'ollama';
$hasKey = isset($p['api_key']) && $p['api_key'] !== '';
@endphp
<div class="llm-prov-card" data-row>
<div class="llm-prov-grid">
<input type="hidden" name="providers[{{ $i }}][id]" value="{{ $pid }}">
<label class="llm-active">
<input type="radio" name="llm_active_id" value="{{ $pid }}" {{ $settings['llm_active_id'] === $pid ? 'checked' : '' }}>
<span>Active</span>
</label>
<input type="text" class="adm-input-full" name="providers[{{ $i }}][name]" placeholder="Label" value="{{ $p['name'] ?? '' }}" required>
<select class="adm-input-full llm-kind" name="providers[{{ $i }}][kind]">
<option value="ollama" {{ $kind === 'ollama' ? 'selected' : '' }}>Ollama (local)</option>
<option value="anthropic" {{ $kind === 'anthropic' ? 'selected' : '' }}>Anthropic Claude</option>
<option value="openai" {{ $kind === 'openai' ? 'selected' : '' }}>OpenAI (or compatible)</option>
</select>
<input type="text" class="adm-input-full llm-endpoint" name="providers[{{ $i }}][endpoint]" placeholder="Endpoint" value="{{ $p['endpoint'] ?? '' }}">
<input type="text" class="adm-input-full llm-model" name="providers[{{ $i }}][model]" placeholder="Model name" value="{{ $p['model'] ?? '' }}" list="llm-models-{{ $pid }}">
<datalist id="llm-models-{{ $pid }}"></datalist>
<input type="password" class="adm-input-full llm-apikey" name="providers[{{ $i }}][api_key]" autocomplete="new-password"
placeholder="{{ $hasKey ? '••••••••' : 'API key (blank for Ollama)' }}">
<button type="button" class="adm-btn llm-test" onclick="llmTestProvider(this)" title="Test connection & load models">
<i class="bi bi-plug llm-test-icon"></i> <span>Test</span>
</button>
<button type="button" class="adm-btn adm-btn-danger llm-remove" onclick="this.closest('[data-row]').remove();" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="llm-prov-status"></div>
<div class="llm-model-chips"></div>
</div>
@endforeach
</div>
<button type="button" id="llmAddProvider" class="adm-btn" style="margin-top:14px;">
<i class="bi bi-plus-lg"></i> Add provider
</button>
<template id="llmProviderTpl">
<div class="llm-prov-card" data-row>
<div class="llm-prov-grid">
<input type="hidden" name="providers[__I__][id]" value="">
<label class="llm-active">
<input type="radio" name="llm_active_id" value="">
<span>Active</span>
</label>
<input type="text" class="adm-input-full" name="providers[__I__][name]" placeholder="Label" required>
<select class="adm-input-full llm-kind" name="providers[__I__][kind]">
<option value="ollama">Ollama (local)</option>
<option value="anthropic">Anthropic Claude</option>
<option value="openai">OpenAI (or compatible)</option>
</select>
<input type="text" class="adm-input-full llm-endpoint" name="providers[__I__][endpoint]" placeholder="http://localhost:11434">
<input type="text" class="adm-input-full llm-model" name="providers[__I__][model]" placeholder="Test connection to load models">
<input type="password" class="adm-input-full llm-apikey" name="providers[__I__][api_key]" autocomplete="new-password" placeholder="API key (blank for Ollama)">
<button type="button" class="adm-btn llm-test" onclick="llmTestProvider(this)" title="Test connection & load models">
<i class="bi bi-plug llm-test-icon"></i> <span>Test</span>
</button>
<button type="button" class="adm-btn adm-btn-danger llm-remove" onclick="this.closest('[data-row]').remove();" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="llm-prov-status"></div>
<div class="llm-model-chips"></div>
</div>
</template>
</div>
</div>
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save AI Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
(function () {
var listEl = document.getElementById('llmProvidersList');
var btn = document.getElementById('llmAddProvider');
var tpl = document.getElementById('llmProviderTpl');
if (!listEl || !btn || !tpl) return;
function nextIdx() { return listEl.querySelectorAll('[data-row]').length; }
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function endpointDefault(kind) {
return kind === 'anthropic' ? 'https://api.anthropic.com'
: kind === 'openai' ? 'https://api.openai.com'
: 'http://localhost:11434';
}
btn.addEventListener('click', function () {
var i = nextIdx();
var html = tpl.innerHTML.replace(/__I__/g, i);
var wrap = document.createElement('div');
wrap.innerHTML = html.trim();
var row = wrap.firstElementChild;
var id = uuid();
row.querySelector('input[name="providers['+i+'][id]"]').value = id;
row.querySelector('input[name="llm_active_id"]').value = id;
// Attach a datalist so the model field gets typeahead after a Test.
var modelInput = row.querySelector('.llm-model');
if (modelInput) {
var dl = document.createElement('datalist');
dl.id = 'llm-models-' + id;
modelInput.setAttribute('list', dl.id);
row.appendChild(dl);
}
row.querySelector('.llm-kind').addEventListener('change', function (e) {
var ep = row.querySelector('input[name="providers['+i+'][endpoint]"]');
if (ep && !ep.value) ep.value = endpointDefault(e.target.value);
});
listEl.appendChild(row);
});
})();
// ── Test connection + load models ─────────────────────────────
async function llmTestProvider(btn) {
const row = btn.closest('[data-row]');
if (! row) return;
const idInput = row.querySelector('input[name$="[id]"]');
const kind = row.querySelector('.llm-kind').value;
const endpoint = row.querySelector('.llm-endpoint').value.trim();
const apiKey = row.querySelector('.llm-apikey').value;
const modelIn = row.querySelector('.llm-model');
const statusEl = row.querySelector('.llm-prov-status');
const chipsEl = row.querySelector('.llm-model-chips');
const icon = btn.querySelector('.llm-test-icon');
const label = btn.querySelector('span');
const dataList = row.querySelector('datalist');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin llm-test-icon';
label.textContent = 'Testing…';
statusEl.dataset.state = 'info';
statusEl.innerHTML = '<i class="bi bi-hourglass-split"></i> Probing endpoint…';
chipsEl.classList.remove('show');
chipsEl.innerHTML = '';
try {
const res = await fetch('{{ route("admin.settings.llm-test") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
body: JSON.stringify({
id: idInput ? idInput.value : '',
kind: kind,
endpoint: endpoint,
api_key: apiKey,
}),
});
const data = await res.json();
if (! data.ok) {
statusEl.dataset.state = 'error';
statusEl.innerHTML = '<i class="bi bi-x-circle-fill"></i> <span>' +
_llmEsc(data.message || 'Connection failed') + '</span>';
} else {
const n = data.count || 0;
statusEl.dataset.state = 'success';
statusEl.innerHTML = '<i class="bi bi-check-circle-fill"></i> <span>Connected — ' +
n + ' model' + (n === 1 ? '' : 's') + ' available. Click one to select.</span>';
// Refresh datalist
if (dataList) {
dataList.innerHTML = '';
(data.models || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m;
dataList.appendChild(opt);
});
}
// Render clickable chips
chipsEl.innerHTML = '';
(data.models || []).forEach(m => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'llm-model-chip' + (m === (modelIn.value || '').trim() ? ' selected' : '');
chip.textContent = m;
chip.addEventListener('click', () => {
modelIn.value = m;
chipsEl.querySelectorAll('.llm-model-chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
});
chipsEl.appendChild(chip);
});
if ((data.models || []).length) chipsEl.classList.add('show');
}
} catch (e) {
statusEl.dataset.state = 'error';
statusEl.innerHTML = '<i class="bi bi-x-circle-fill"></i> <span>' + _llmEsc(e.message) + '</span>';
}
icon.className = 'bi bi-plug llm-test-icon';
label.textContent = 'Test';
btn.disabled = false;
}
function _llmEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
@endsection