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>
357 lines
18 KiB
PHP
357 lines
18 KiB
PHP
@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>
|
|
|
|
@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/alpinejs@3.x.x/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
|