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>
414 lines
19 KiB
PHP
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
@endsection
|