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:
ghassanandClaude Opus 4.7 committed 2026-05-31 22:01:47 +03:00
1 parent 73527f3781
commit f98e5415a3
60 files changed
+7920 -2675

No files matched your search

@@ -246,7 +246,7 @@
<div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;"
onmouseover="this.style.background='#3f3f3f'"
onmouseout="this.style.background='transparent'"
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId})">
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId}, ${isInPlaylist ? 'true' : 'false'})">
<div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;">
<div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;">
${playlist.thumbnail_url
@@ -312,33 +312,38 @@
@endauth
}
// Toggle video in playlist
function toggleVideoInPlaylist(playlistId, videoId) {
// Toggle video in/out of a playlist. `currentlyIn` reflects what the row
// showed when the user clicked, so a single click flips the state via the
// matching REST endpoint (POST to add, DELETE to remove).
function toggleVideoInPlaylist(playlistId, videoId, currentlyIn) {
if (!videoId) return;
// Check authentication before adding
if (!isAuthenticated()) {
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
return;
}
fetch(`/playlists/${playlistId}/videos`, {
method: 'POST',
// Both add and remove go through the same /playlists/{id}/videos URL with
// a JSON body carrying the numeric video id — keeps the front-end ignorant
// of the encoded route key used by GET /videos/{video}.
var url = `/playlists/${playlistId}/videos`;
var method = currentlyIn ? 'DELETE' : 'POST';
var body = JSON.stringify({ video_id: videoId });
fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
video_id: videoId
})
body
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || 'Video added to playlist');
// Reload playlists to update checkmarks
loadPlaylistsForModal(videoId);
showToast(data.message || (currentlyIn ? 'Removed from playlist' : 'Added to playlist'));
loadPlaylistsForModal(videoId); // refresh checkmarks
}
})
.catch(error => console.error('Error:', error));
@@ -1661,11 +1661,18 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
const formData = new FormData(this);
if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_slidesData)) {
if (tid === 't1') continue;
const n = parseInt(tid.replace('e', ''));
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
}
// Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
extraForms.forEach(function (el, domIdx) {
var m = el.id.match(/^um-tf-(e\d+)$/);
if (!m) return;
var tid = m[1];
var files = _slidesData[tid] || [];
files.forEach(function (f) {
formData.append('extra_track_slides[' + domIdx + '][]', f);
});
});
}
const xhr = new XMLHttpRequest();