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

+152 -36
View File
@@ -122,6 +122,49 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.
**Every new Blade page must ship desktop + mobile style partials from day one.** When you create a view at `resources/views/<scope>/<page>.blade.php`, also create:
```
resources/views/<scope>/partials/<page>/styles/
├── desktop.blade.php ← base + desktop CSS (the foundation)
└── mobile.blade.php ← @media (max-width: 768px) and below
```
…and wire them up in the page's `@section('extra_styles')` block:
```blade
@section('extra_styles')
@php
// Any shared Blade variables consumed by BOTH partials (palette,
// computed sizes, theme values) must be defined here, not inside
// a partial — each @include runs in its own variable scope.
@endphp
@include('<scope>.partials.<page>.styles.desktop')
@include('<scope>.partials.<page>.styles.mobile')
@endsection
```
Rules that must never be violated:
1. **Never put a `@media (max-width: ...)` block inside `desktop.blade.php`.** All mobile-scoped rules go in `mobile.blade.php`. The whole point is that editing one cannot affect the other.
2. **Never put a non-media-query rule inside `mobile.blade.php`.** Every selector in the mobile partial must be inside an `@media (max-width: 768px)` (or smaller) block. A naked rule would leak to desktop.
3. **Folder name is `styles/`, not `styles.`.** Laravel resolves dots in `@include('foo.bar.baz')` as directory separators, so a file called `styles.mobile.blade.php` can't be referenced by `@include('....styles.mobile')`. Always put the two files under a `styles/` subdirectory.
4. **Shared `@php` variables go in the parent page**, never duplicated across partials. Define `$hue`, palette values, computed sizes, etc. in the page's `@section('extra_styles')` block above the `@include`s, so both partials inherit them.
5. **Reference example:** the channel page (`resources/views/user/channel.blade.php` + `resources/views/user/partials/channel/styles/{desktop,mobile}.blade.php`) is the canonical implementation. Mirror its structure on every new page.
6. **No “I'll add mobile styles later.”** A page without a mobile partial is not finished. Create `mobile.blade.php` with at minimum the empty media-query scaffold:
```blade
<style>
@media (max-width: 768px) {
/* mobile overrides for <page> */
}
@media (max-width: 480px) {
/* small-phone refinements */
}
</style>
```
…even when there are no mobile-specific rules yet. This guarantees the file exists for the next person who needs to tweak mobile.
7. **When editing an existing page that still has inline styles**, refactor it to this structure as part of the same task — don't add new CSS to a page that hasn't been split yet without splitting it first.
**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**
**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** — this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.
@@ -190,71 +233,144 @@ Rules that must never be violated:
**NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.**
#### Framework storage lives at `data/`, not `storage/`
**The Laravel framework storage directory has been relocated from `storage/` to `data/` so the only entry named `storage` in the project tree is the `public/storage` symlink.**
Layout (verbatim):
- `data/app/` → file storage (with `app/public/` exposed to the web).
- `data/framework/` → sessions, cache, compiled views, route cache.
- `data/logs/` → application logs.
- `public/storage` → a **symlink** to `../data/app/public`. It exposes public files to the web without making the rest of `data/` reachable.
The redirect is wired in `bootstrap/app.php`:
```php
$app->useStoragePath(base_path('data'));
```
This means every `storage_path(...)` call, `Storage::disk('local'|'public')` operation, session/cache/view/log write, and the local NAS file cache resolves through `data/`. The `storage/` directory at the project root **does not exist** and must never be re-created.
Rules:
1. Never re-create `storage/` at the project root. Laravel's storage path is `data/`. The framework can't run without `data/`.
2. Never delete the `data/` directory or any subdirectory of it.
3. Never replace the `public/storage` symlink with a real directory or copy files into it. It must remain a symlink targeting `../data/app/public`.
4. Never move user files into `public/storage` directly. All file writes go through the `data/app/` tree (NAS-mirrored paths), and the symlink + `MediaController` handle public serving.
5. If `public/storage` is missing or broken, fix it with `ln -sfn ../data/app/public public/storage`. Do not run `php artisan storage:link` blindly — that targets `data/app/public` which doesn't exist; you'd have to pass `--relative` and a custom target.
6. Nginx must alias `/storage` to `/var/www/videoplatform/data/app/public` (set in `/etc/nginx/sites-enabled/videoplatform`). If you ever edit that file, keep the alias pointing at `data/`, not `storage/`.
#### Canonical storage layout — IDENTICAL on local disk and on the NAS
**This is the single source-of-truth file structure. Both the local `storage/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**
**This is the single source-of-truth file structure. Both the local `data/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**
**Type-segregated top-level folders.** Every video has a `type` (`music`, `match`, `generic`). The folder it lives under is determined by that type and is frozen at upload time — editing a video's type does NOT move its files. The mapping is:
| Video `type` | Folder | Has `tracks/` subfolder? |
|---|---|---|
| `music` | `music/` | **Yes** — every track (primary + extras) lives in its own subfolder |
| `match` (sports) | `sports/` | No — single video file in the slug folder |
| `generic` | `videos/` | No — single video file in the slug folder |
```
users/{user-slug}/
├── profile/
│ ├── avatar.{ext}
│ └── cover.{ext} ← banner (DB column is users.banner)
│ └── cover.{ext} ← banner (DB column is users.banner)
├── playlists/
│ └── {playlist-id}/
│ └── thumb.{ext}
├── posts/
│ └── {post-id}/
│ └── {filename} ← post images
└── videos/
└── {song-slug}/ ← ONE folder per song/video. EVERYTHING for it lives here.
│ ┌──────────────── SOURCE OF TRUTH (synced to NAS) ───────┐
├── {title-slug}.{ext} ← primary track / video file (canonical name)
├── {song-slug}-{lang}-{id}.{ext} ← each extra-language audio track (one per language)
├── slides/
│ └── {position}.{ext} ← cover image(s) / slideshow frames
├── thumb.{ext} ← cover for video-type uploads that have no slides
├── meta.json ← {id, user_id, title, created_at}
│ └──────────────────────────────────────────────────────┘
└── cache/ ← REGENERABLE renders. LOCAL-ONLY. Never on NAS. Safe to wipe.
├── video.mp4 ← generated "Download Video" (plain)
├── video-viz.mp4 ← generated "Download Video" (visualizer)
└── hls/
└── {variant}/… ← adaptive-streaming rendition (.m3u8 + .ts)
│ └── {filename} ← post images
│
├── music/ ← type = music
│ └── {song-slug}/ ← ONE folder per song
│ ├── meta.json ← {id, user_id, title, type:"music", created_at}
│ └── tracks/
│ ├── {primary-lang}-{primary-track-id}/ ← primary track has its own folder
│ │ │ ┌─── SOURCE OF TRUTH (synced to NAS) ───┐
│ │ ├── audio.{ext} ← the audio file (canonical name)
│ │ ├── lyrics.ass ← synced lyrics for THIS track
│ │ ├── thumb.{ext} ← cover when this track has no slides
│ │ ├── slides/
│ │ │ └── {position}.{ext} ← THIS track's slideshow frames
│ │ │ └────────────────────────────────────────┘
│ │ └── cache/ ← LOCAL-only, regenerable, never on NAS
│ │ ├── video.mp4
│ │ ├── video-viz.mp4
│ │ └── hls/{variant}/…
│ └── {extra-lang}-{extra-track-id}/ ← every extra-language track, same shape
│ ├── audio.{ext}
│ ├── lyrics.ass
│ ├── thumb.{ext}
│ ├── slides/{position}.{ext}
│ └── cache/…
│
├── sports/ ← type = match
│ └── {match-slug}/
│ │ ┌─── SOURCE OF TRUTH ───┐
│ ├── meta.json
│ ├── video.{ext} ← the match video
│ ├── thumb.{ext}
│ │ └────────────────────────┘
│ └── cache/ ← LOCAL-only
│ └── hls/{variant}/…
│
└── videos/ ← type = generic
└── {video-slug}/
│ ┌─── SOURCE OF TRUTH ───┐
├── meta.json
├── video.{ext}
├── thumb.{ext}
│ └────────────────────────┘
└── cache/ ← LOCAL-only
└── hls/{variant}/…
```
**Sources vs. the `cache/` subfolder — a hard rule:**
- The song-folder **root** holds only the **source of truth** (primary track, extra-language tracks, slides, thumb, meta.json). These are what gets pushed to / pulled from the NAS.
- **`cache/` holds only regenerable, derived renders** — the "Download Video" mp4s and the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (`syncVideo()` pushes only the source files). Deleting `cache/` (or the whole subtree) is always safe — it rebuilds on the next download/stream. There must be **no shared `public/slideshow` or `public/hls` caches** anymore; every render lives under its song's `cache/`.
- DB pointers: `videos.slideshow_video_path` → `users/.../{song}/cache/video.mp4`; `videos.hls_path` → `users/.../{song}/cache/hls`.
- Reclaim space anytime with `php artisan nas:free-local-storage` (deletes song `cache/` folders); `tracks:reorganize` never treats anything under `cache/` as an orphan.
- The track-folder root (or video-folder root for sports/generic) holds only the **source of truth** (audio/video file, slides, thumb, lyrics, meta.json). These are what gets pushed to / pulled from the NAS.
- **`cache/` holds only regenerable, derived renders** — "Download Video" mp4s, the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (the sync layer pushes only source files). Deleting `cache/` is always safe; it rebuilds on next download/stream.
- DB pointers: `videos.slideshow_video_path` → `users/.../<track-folder>/cache/video.mp4` (music) or `users/.../<video-folder>/cache/video.mp4` (sports/generic). `videos.hls_path` → the parent `cache/hls` for that file.
- Reclaim space anytime with `php artisan nas:free-local-storage`; `tracks:reorganize` never treats anything under `cache/` as an orphan.
**Naming rules (apply on both local and NAS, always lowercase, Unicode-preserving slug via `NasSyncService::titleSlug()`):**
- **One folder per song/video** at `users/{slug}/videos/{song-slug}/`. There is **NO `tracks/` subfolder** — every audio track (primary AND secondary) sits directly in this folder.
- **Primary** keeps the canonical `{title-slug}.{ext}` (the name the NAS layer reconstructs in `uploadDirectToNas()` / `syncVideo()` — do not change this scheme).
- **Each extra-language track** is `{song-slug}-{lang}-{db-track-id}.{ext}`. The DB id makes every track filename globally unique, so no two tracks can ever overwrite each other even within the same folder/language.
- Slides are `slides/{position}.{ext}`.
- **Music — one song folder, one folder per track inside it:**
- Song folder: `users/{slug}/music/{song-slug}/`.
- Track folder name: `{lang}-{db-track-id}` (e.g. `en-12`, `ar-47`). The DB id makes the folder name globally unique even when two tracks share a language.
- Inside each track folder, filenames are **canonical** — `audio.{ext}`, `lyrics.ass`, `thumb.{ext}`, `slides/{position}.{ext}`. **Do not** put track-id or language in these filenames; the *folder* already disambiguates.
- **Sports (match):** `users/{slug}/sports/{match-slug}/video.{ext}`, `thumb.{ext}`.
- **Generic:** `users/{slug}/videos/{video-slug}/video.{ext}`, `thumb.{ext}`.
**Type is frozen at upload time.** When a user edits a video's type (e.g. `generic` → `music`), the on-disk folder does NOT move. The path remains under the original type folder for the life of that record. New uploads use the type-aware path. The migration command (`tracks:reorganize`) is the only thing that may move files between type folders.
File types and their canonical locations (same string on NAS and local):
| File type | Path (relative to NAS root and to `storage/app/`) | Served via |
| File type | Path (relative to NAS root and to `data/app/`) | Served via |
|---|---|---|
| Primary video / audio | `users/{slug}/videos/{song-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Extra audio track | `users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
| Video thumbnail | `users/{slug}/videos/{song-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Slides | `users/{slug}/videos/{song-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Music primary audio | `users/{slug}/music/{song-slug}/tracks/{lang}-{primary-id}/audio.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Music extra audio track | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/audio.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
| Music track lyrics | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/lyrics.ass` | `NasSyncService::getLocalLyrics()` |
| Music track slides | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Music track thumb | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Sports video | `users/{slug}/sports/{match-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Sports thumb | `users/{slug}/sports/{match-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Generic video | `users/{slug}/videos/{video-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Generic thumb | `users/{slug}/videos/{video-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs (dry-run by default; `--force` to apply).
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs/videos (dry-run by default; `--force` to apply). It moves any pre-existing flat-layout content into the type-segregated, per-track-folder layout above and updates the DB pointers in lockstep.
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**Slide sharing across tracks (music only):** A music track owns its own slides via `video_slides.audio_track_id`. If a track has no slides of its own, the player and the render pipeline fall back via `Video::slidesForTrack($trackId)` to: (1) the primary track's slides, then (2) any other track's slides, then (3) the cover image. Files are never duplicated to support this — the fallback is purely a query/runtime concern.
The only files that live permanently on local disk are HLS segments (`data/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
- `data/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `data/app/public/avatars/` — formerly held user avatars; now NAS only
- `data/app/public/videos/` — formerly held uploaded video files; now NAS only
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.