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
@@ -0,0 +1,69 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Backup & Restore')
|
||||
@section('page_title', 'Backup & Restore')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-archive"></i> Backup & Restore</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
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-archive"></i>
|
||||
Users & Settings Backup
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Export users & settings</strong>
|
||||
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-download"></i> Download Backup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Restore users & settings</strong>
|
||||
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
|
||||
@csrf
|
||||
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none"
|
||||
onchange="document.getElementById('restoreForm').submit()">
|
||||
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
|
||||
<i class="bi bi-upload"></i> Upload & Restore
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,283 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'GPU Accelerator')
|
||||
@section('page_title', 'GPU Accelerator')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-gpu-card"></i> GPU Accelerator</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="gpuForm">
|
||||
@csrf
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-gpu-card"></i>
|
||||
GPU Accelerated Processing
|
||||
<span id="gpuStatusChip" style="margin-left:6px;">
|
||||
@if(count($gpus))
|
||||
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
|
||||
@else
|
||||
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
|
||||
@endif
|
||||
</span>
|
||||
<span id="nvencStatusChip" style="margin-left:6px;">
|
||||
@if($nvencWorks)
|
||||
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
|
||||
@else
|
||||
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(!$nvencWorks && count($gpus))
|
||||
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
|
||||
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
|
||||
The GPU is detected but FFmpeg cannot initialise CUDA on this driver.<br>
|
||||
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
|
||||
Until then, video encoding will automatically fall back to CPU (libx264).
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||||
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
|
||||
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
|
||||
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
|
||||
</button>
|
||||
</div>
|
||||
<div id="gpuCardsWrap">
|
||||
@if(count($gpus))
|
||||
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
|
||||
@else
|
||||
<div class="no-gpu-state">
|
||||
<i class="bi bi-gpu-card"></i>
|
||||
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Enable GPU acceleration</strong>
|
||||
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
|
||||
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label" id="gpuEnabledLabel">
|
||||
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</label>
|
||||
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
|
||||
value="{{ $settings['gpu_enabled'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
|
||||
|
||||
<div class="setting-row" id="gpuEncoderRow">
|
||||
<div class="setting-label">
|
||||
<strong>Video encoder</strong>
|
||||
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="enc-grid">
|
||||
@foreach([
|
||||
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
|
||||
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
|
||||
['libx264', 'libx264', 'CPU · software fallback'],
|
||||
] as [$val, $label, $desc])
|
||||
<button type="button"
|
||||
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
|
||||
data-encoder="{{ $val }}"
|
||||
onclick="selectEncoder(this)">
|
||||
<span class="enc-card-name">{{ $label }}</span>
|
||||
<span class="enc-card-desc">{{ $desc }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="gpuPresetRow">
|
||||
<div class="setting-label">
|
||||
<strong>Encoding preset</strong>
|
||||
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
|
||||
<optgroup label="NVENC (GPU)">
|
||||
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
|
||||
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
||||
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="libx264 (CPU)">
|
||||
@foreach(['fast','medium','slow'] as $p)
|
||||
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
||||
{{ $p }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="gpuHwaccelRow">
|
||||
<div class="setting-label">
|
||||
<strong>Hardware decode acceleration</strong>
|
||||
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select name="gpu_hwaccel" class="adm-select-full">
|
||||
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
|
||||
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>FFmpeg binary path</strong>
|
||||
<small>
|
||||
Absolute path to the <code>ffmpeg</code> executable.
|
||||
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
|
||||
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" name="ffmpeg_binary" class="adm-input-full"
|
||||
value="{{ $settings['ffmpeg_binary'] }}"
|
||||
placeholder="/usr/bin/ffmpeg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 GPU Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
function selectGpuCard(el) {
|
||||
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
document.getElementById('gpuDeviceInput').value = el.dataset.index;
|
||||
}
|
||||
|
||||
function selectEncoder(el) {
|
||||
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
|
||||
const isCpu = el.dataset.encoder === 'libx264';
|
||||
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
||||
}
|
||||
|
||||
const gpuToggle = document.getElementById('gpuEnabledInput');
|
||||
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
||||
const gpuLabel = document.getElementById('gpuEnabledLabel');
|
||||
function applyGpuToggle() {
|
||||
const on = gpuToggle.checked;
|
||||
gpuHidden.value = on ? 'true' : 'false';
|
||||
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
gpuToggle.addEventListener('change', applyGpuToggle);
|
||||
|
||||
async function detectGpus() {
|
||||
const btn = document.getElementById('detectBtn');
|
||||
const icon = document.getElementById('detectIcon');
|
||||
btn.disabled = true;
|
||||
icon.className = 'bi bi-arrow-repeat spin';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
|
||||
const data = await res.json();
|
||||
const gpus = data.gpus || [];
|
||||
const wrap = document.getElementById('gpuCardsWrap');
|
||||
const chip = document.getElementById('gpuStatusChip');
|
||||
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
|
||||
|
||||
if (gpus.length === 0) {
|
||||
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
|
||||
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
|
||||
} else {
|
||||
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
|
||||
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-arrow-repeat';
|
||||
}
|
||||
|
||||
function buildGpuCards(gpus, selectedDevice) {
|
||||
if (!gpus.length) return '';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'gpu-grid';
|
||||
gpus.forEach(gpu => {
|
||||
const used = gpu.mem_total - gpu.mem_free;
|
||||
const usedPct = Math.round((used / gpu.mem_total) * 100);
|
||||
const sel = gpu.index === selectedDevice;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'gpu-card' + (sel ? ' selected' : '');
|
||||
card.dataset.index = gpu.index;
|
||||
card.onclick = function() { selectGpuCard(this); };
|
||||
card.innerHTML = `
|
||||
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
|
||||
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
|
||||
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
|
||||
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
|
||||
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
|
||||
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
|
||||
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
|
||||
<div class="mem-bar-wrap"><div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div></div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
return grid.outerHTML;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -597,7 +597,23 @@
|
||||
<div class="adm-nav-section" style="margin-top:8px;">System</div>
|
||||
<a href="{{ route('admin.settings') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.settings*') ? 'active' : '' }}">
|
||||
<i class="bi bi-gpu-card"></i> Settings
|
||||
<i class="bi bi-stars"></i> AI / LLM
|
||||
</a>
|
||||
<a href="{{ route('admin.lyrics') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.lyrics') ? 'active' : '' }}">
|
||||
<i class="bi bi-music-note-list"></i> Lyrics Pipeline
|
||||
</a>
|
||||
<a href="{{ route('admin.gpu') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.gpu') ? 'active' : '' }}">
|
||||
<i class="bi bi-gpu-card"></i> GPU Accelerator
|
||||
</a>
|
||||
<a href="{{ route('admin.nas-storage') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
</a>
|
||||
<a href="{{ route('admin.backup') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.backup') ? 'active' : '' }}">
|
||||
<i class="bi bi-archive"></i> Backup & Restore
|
||||
</a>
|
||||
<a href="{{ route('admin.audit') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.audit') ? 'active' : '' }}">
|
||||
@@ -607,10 +623,6 @@
|
||||
class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}">
|
||||
<i class="bi bi-bug"></i> Error Logs
|
||||
</a>
|
||||
<a href="{{ route('admin.nas-storage') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="adm-nav-divider"></div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Lyrics Pipeline')
|
||||
@section('page_title', 'Lyrics Pipeline')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-music-note-list"></i> Lyrics Pipeline</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
|
||||
|
||||
<form method="POST" action="{{ route('admin.settings.update') }}" id="lyricsForm">
|
||||
@csrf
|
||||
<input type="hidden" name="lyrics_section" value="1">
|
||||
|
||||
{{-- ── Master switch ─────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-toggle-on"></i>
|
||||
Master
|
||||
@if($settings['lyrics_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">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Lyrics generation</strong>
|
||||
<small>
|
||||
Master switch for the whole feature. When OFF, the Generate / Regenerate
|
||||
button in the player gear is hidden and the API endpoint refuses new jobs.
|
||||
Existing lyrics keep displaying — only NEW generation is blocked.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_enabled" value="true" {{ $settings['lyrics_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Allow new lyrics jobs</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Pipeline steps ────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
Pipeline steps
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Description-based correction</strong>
|
||||
<small>
|
||||
Use the song description as the source of truth for lyric text. Whisper provides
|
||||
timing anchors; the description provides the exact words. When OFF, the player
|
||||
shows Whisper's raw transcription (may contain misheard words and miss verses).
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_use_description" value="true" {{ $settings['lyrics_use_description'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Use description as ground truth</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Voice activity filter (VAD)</strong>
|
||||
<small>
|
||||
Runs Silero VAD before Whisper to skip pure-instrumental sections. Without this,
|
||||
Whisper invents lyrics over intro / outro / breakdown music. When OFF, the entire
|
||||
audio is transcribed — useful as a fallback if VAD is dropping legitimate quiet vocals.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_vad_enabled" value="true" {{ $settings['lyrics_vad_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Drop instrumentals via VAD</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Vocal-region gap-filling</strong>
|
||||
<small>
|
||||
For description lines Whisper missed, snap them to moments where vocals are
|
||||
active (detected by VAD) instead of distributing evenly across time. Keeps
|
||||
gap-filled lyrics on actual singing. When OFF, missing lines distribute uniformly
|
||||
between Whisper anchors (faster but can drift over instrumental beats).
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_vocal_region_gapfill" value="true" {{ $settings['lyrics_vocal_region_gapfill'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Snap missing lines to vocal regions</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Vocal isolation (Demucs)</strong>
|
||||
<small>
|
||||
Two-stem source separation (vocals vs. instruments) before Whisper. Improves
|
||||
transcription on noisy mixes but adds ~30s GPU time and has caused a CUDA-handoff
|
||||
deadlock that hangs the pipeline at 50%. Currently auto-skipped when a description
|
||||
is present (timing-only mode). Enable to also use Demucs when no description is given.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_demucs_enabled" value="true" {{ $settings['lyrics_demucs_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Use Demucs when no description</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>LLM emoji decoration</strong>
|
||||
<small>
|
||||
After lyrics are saved, run the active LLM provider to bake one or more emojis
|
||||
into each line. Without this, the player still decorates lines using a built-in
|
||||
keyword-to-emoji map (less varied). Provider is configured under
|
||||
<a href="{{ route('admin.settings') }}" style="color:var(--brand)">AI / LLM Settings</a>.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_llm_decorate" value="true" {{ $settings['lyrics_llm_decorate'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Bake emojis via LLM</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 Lyrics Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@endsection
|
||||
@@ -1,23 +1,356 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'NAS Storage')
|
||||
@section('page_title', 'NAS Storage')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
<style>
|
||||
.nas-repair-result { padding: 14px 0 0; font-size: 13px; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<span class="chip chip-green" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Enabled</span>
|
||||
@else
|
||||
<span class="chip chip-red" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Disabled</span>
|
||||
@endif
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
@include('nas-file-manager::file-manager', [
|
||||
'nodes' => $nodes,
|
||||
'canEdit' => true,
|
||||
'title' => 'NAS Storage Browser',
|
||||
])
|
||||
@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
|
||||
|
||||
{{-- ── NAS Settings ─────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-sliders"></i>
|
||||
NAS Settings
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Use NAS as primary storage</strong>
|
||||
<small>
|
||||
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
||||
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
||||
When disabled, all files are served from local disk using the same directory schema.
|
||||
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
|
||||
<i class="bi bi-hdd-network"></i> Disable NAS
|
||||
</button>
|
||||
@else
|
||||
<span style="font-size:13px;color:var(--text-2);">NAS is disabled. Re-enabling is handled by the system once a NAS endpoint is reachable.</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Repair stuck files</strong>
|
||||
<small>
|
||||
Scans for files that were saved locally but never reached the NAS (e.g. due to a
|
||||
connection error during upload or edit). Uploads them to the NAS, then removes the
|
||||
local copies. Safe to run at any time — nothing is deleted until the NAS confirms receipt.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button type="button" id="nasRepairScanBtn" class="adm-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-search" id="nasRepairScanIcon"></i> Scan
|
||||
</button>
|
||||
<button type="button" id="nasRepairFixBtn" class="adm-btn adm-btn-primary" style="white-space:nowrap;display:none;">
|
||||
<i class="bi bi-arrow-repeat" id="nasRepairFixIcon"></i> Fix All
|
||||
</button>
|
||||
</div>
|
||||
<div id="nasRepairResult" class="nas-repair-result" style="display:none;">
|
||||
<div id="nasRepairResultInner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── NAS File Browser ──────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
NAS File Browser
|
||||
</div>
|
||||
<div class="settings-section-body" style="padding: 0;">
|
||||
@include('nas-file-manager::file-manager', [
|
||||
'nodes' => $nodes,
|
||||
'canEdit' => true,
|
||||
'title' => 'NAS Storage Browser',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
|
||||
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
|
||||
|
||||
<div id="nasDisableStep1">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
|
||||
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
|
||||
|
||||
<div style="display:grid;gap:12px;margin-bottom:24px;">
|
||||
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
|
||||
<strong style="font-size:14px;">Copy all files to local disk</strong>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/…</code>. Same directory structure — everything keeps working. May take a while.</p>
|
||||
</div>
|
||||
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
|
||||
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nasDisableStep2Migrate" style="display:none;">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk…</h3>
|
||||
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting…</p>
|
||||
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
|
||||
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
|
||||
</div>
|
||||
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
|
||||
<div id="nasDisableDone" style="display:none;">
|
||||
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
|
||||
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
|
||||
</div>
|
||||
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="nasDisableStep2Fresh" style="display:none;">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
|
||||
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
|
||||
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input-full" style="margin-bottom:8px;">
|
||||
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete & Disable NAS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" defer></script>
|
||||
<script>
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── NAS Repair ────────────────────────────────────────────────
|
||||
(function () {
|
||||
const scanBtn = document.getElementById('nasRepairScanBtn');
|
||||
const fixBtn = document.getElementById('nasRepairFixBtn');
|
||||
const resultEl = document.getElementById('nasRepairResult');
|
||||
const inner = document.getElementById('nasRepairResultInner');
|
||||
const scanIcon = document.getElementById('nasRepairScanIcon');
|
||||
const fixIcon = document.getElementById('nasRepairFixIcon');
|
||||
if (! scanBtn) return;
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
let stuckCount = 0;
|
||||
|
||||
function showResult(html, type = 'info') {
|
||||
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-2)' };
|
||||
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:6px 0;">${html}</div>`;
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', async function () {
|
||||
scanBtn.disabled = true;
|
||||
fixBtn.style.display = 'none';
|
||||
scanIcon.className = 'bi bi-arrow-repeat spin';
|
||||
showResult('Scanning…', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify({ scan_only: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
stuckCount = data.stuck ?? 0;
|
||||
|
||||
if (stuckCount === 0) {
|
||||
showResult('✅ All clear — no stuck local files found.', 'success');
|
||||
} else {
|
||||
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
|
||||
if (data.details && data.details.length) {
|
||||
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||
}
|
||||
html += `<p style="margin-top:10px;color:var(--text-2);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
|
||||
showResult(html, 'warning');
|
||||
fixBtn.style.display = '';
|
||||
}
|
||||
} catch (e) {
|
||||
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
|
||||
}
|
||||
|
||||
scanIcon.className = 'bi bi-search';
|
||||
scanBtn.disabled = false;
|
||||
});
|
||||
|
||||
fixBtn.addEventListener('click', async function () {
|
||||
fixBtn.disabled = true;
|
||||
scanBtn.disabled = true;
|
||||
fixIcon.className = 'bi bi-arrow-repeat spin';
|
||||
showResult('Uploading to NAS…', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ url("/admin/nas-repair") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
const type = data.failed > 0 ? 'warning' : 'success';
|
||||
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
|
||||
if (data.details && data.details.length) {
|
||||
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||
}
|
||||
showResult(html, type);
|
||||
fixBtn.style.display = 'none';
|
||||
} catch (e) {
|
||||
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
|
||||
}
|
||||
|
||||
fixIcon.className = 'bi bi-arrow-repeat';
|
||||
fixBtn.disabled = false;
|
||||
scanBtn.disabled = false;
|
||||
});
|
||||
})();
|
||||
|
||||
// ── NAS Disable Modal ────────────────────────────────────────
|
||||
let _nasOpt = null;
|
||||
let _nasPollTimer = null;
|
||||
|
||||
function openNasDisableModal() {
|
||||
_nasOpt = null;
|
||||
document.getElementById('nasDisableModal').style.display = 'flex';
|
||||
document.getElementById('nasDisableStep1').style.display = 'block';
|
||||
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
|
||||
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
|
||||
document.getElementById('nasDisableNextBtn').disabled = true;
|
||||
['optMigrate','optFresh'].forEach(id => {
|
||||
document.getElementById(id).style.borderColor = 'var(--border)';
|
||||
});
|
||||
}
|
||||
function closeNasDisableModal() {
|
||||
if (_nasPollTimer) clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableModal').style.display = 'none';
|
||||
}
|
||||
function selectNasOpt(opt) {
|
||||
_nasOpt = opt;
|
||||
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
|
||||
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
|
||||
document.getElementById('nasDisableNextBtn').disabled = false;
|
||||
}
|
||||
function nasDisableNext() {
|
||||
document.getElementById('nasDisableStep1').style.display = 'none';
|
||||
if (_nasOpt === 'migrate') {
|
||||
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
|
||||
nasStartMigration();
|
||||
} else {
|
||||
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
|
||||
}
|
||||
}
|
||||
async function nasStartMigration() {
|
||||
try {
|
||||
await fetch('{{ route("admin.nas.disable") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ mode: 'migrate' }),
|
||||
});
|
||||
} catch(e) {
|
||||
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
|
||||
document.getElementById('nasDisableError').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
_nasPollTimer = setInterval(nasPollProgress, 2000);
|
||||
}
|
||||
async function nasPollProgress() {
|
||||
try {
|
||||
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
|
||||
const d = await r.json();
|
||||
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
|
||||
document.getElementById('nasDisableBar').style.width = pct + '%';
|
||||
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
|
||||
document.getElementById('nasDisablePhase').textContent = d.phase || '';
|
||||
if (d.error) {
|
||||
clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
|
||||
document.getElementById('nasDisableError').style.display = 'block';
|
||||
}
|
||||
if (d.done) {
|
||||
clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableBar').style.width = '100%';
|
||||
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
|
||||
document.getElementById('nasDisableDone').style.display = 'block';
|
||||
}
|
||||
} catch(e) { /* network blip, keep polling */ }
|
||||
}
|
||||
async function nasDisableFreshConfirm() {
|
||||
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
|
||||
const errEl = document.getElementById('nasDeleteConfirmError');
|
||||
if (val !== 'DELETE') {
|
||||
errEl.textContent = 'Type DELETE (all caps) to confirm.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
const r = await fetch('{{ route("admin.nas.disable") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ mode: 'fresh' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
closeNasDisableModal();
|
||||
location.reload();
|
||||
} else {
|
||||
errEl.textContent = d.message || 'An error occurred.';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch(e) {
|
||||
errEl.textContent = 'Failed: ' + e.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,124 @@
|
||||
{{-- Shared styles for all admin settings-style pages: GPU, NAS, Backup, Settings. --}}
|
||||
<style>
|
||||
.settings-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-section-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.settings-section-header i { color: var(--brand); font-size: 16px; }
|
||||
.settings-section-body { padding: 22px; }
|
||||
|
||||
.setting-row {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px; padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.setting-row:first-child { padding-top: 0; }
|
||||
.setting-label { flex: 1; min-width: 0; }
|
||||
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
|
||||
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
|
||||
.setting-control { flex-shrink: 0; min-width: 220px; display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
|
||||
.toggle-switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; display: inline-block; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-track {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--border-light); border-radius: 12px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute; top: 3px; left: 3px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #fff; transition: transform .2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
|
||||
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
|
||||
.toggle-label { font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* Select / text inputs in setting rows */
|
||||
.adm-select-full,
|
||||
.adm-input-full {
|
||||
width: 100%; height: 38px; box-sizing: border-box;
|
||||
background: var(--bg); border: 1px solid var(--border-light);
|
||||
border-radius: 8px; color: var(--text); font-size: 13px;
|
||||
padding: 0 12px; outline: none; font-family: inherit;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.adm-select-full { cursor: pointer; }
|
||||
.adm-select-full:focus,
|
||||
.adm-input-full:focus { border-color: var(--brand); }
|
||||
.adm-select-full option { background: #1e1e1e; }
|
||||
|
||||
/* GPU cards */
|
||||
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
|
||||
.gpu-card {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, background .15s;
|
||||
position: relative;
|
||||
}
|
||||
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
|
||||
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
||||
.gpu-card-check {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 2px solid var(--border); background: transparent;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; color: #fff;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
|
||||
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
|
||||
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.gpu-stat:last-child { margin-bottom: 0; }
|
||||
.gpu-stat-val { color: var(--text); font-weight: 500; }
|
||||
.mem-bar-wrap { margin-top: 8px; }
|
||||
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
|
||||
|
||||
/* No GPU state */
|
||||
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
|
||||
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
|
||||
|
||||
/* Status chip */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
|
||||
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
|
||||
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
/* Encoder option cards */
|
||||
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||
.enc-card {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px 14px;
|
||||
cursor: pointer; transition: border-color .15s;
|
||||
text-align: left;
|
||||
}
|
||||
.enc-card:hover { border-color: #444; }
|
||||
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
||||
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
|
||||
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
|
||||
|
||||
.save-bar {
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 12px; padding: 4px 0 24px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user