diff --git a/.gitignore b/.gitignore
index da2fd58..1fc0745 100755
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@
/public/build
/public/hot
/public/storage
-/storage/*.key
+/data/*.key
/vendor
.env
.env.backup
@@ -18,3 +18,12 @@ yarn-error.log
/.idea
/.vscode
/.claude/mcp
+
+# Lyrics ML stack: keep transcribe.py, ignore the heavy venv + model cache
+/ml/venv
+/ml/cache
+/ml/__pycache__
+
+# Runtime storage (moved from /storage to /data) — user uploads, sessions,
+# cache, logs, tmp. Never goes into git.
+/data
diff --git a/CLAUDE.md b/CLAUDE.md
index abc4832..0c8002f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,6 +122,49 @@ Structure: ` Label/.blade.php`, also create:
+
+```
+resources/views//partials//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('.partials..styles.desktop')
+@include('.partials..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
+
+ ```
+ …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/...//cache/video.mp4` (music) or `users/...//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.
diff --git a/app/Console/Commands/GenerateLyrics.php b/app/Console/Commands/GenerateLyrics.php
new file mode 100644
index 0000000..84d9c3b
--- /dev/null
+++ b/app/Console/Commands/GenerateLyrics.php
@@ -0,0 +1,61 @@
+option('force');
+
+ if ($videoId = $this->argument('video')) {
+ $video = Video::find($videoId);
+ if (! $video) {
+ $this->error("Video #{$videoId} not found.");
+ return self::FAILURE;
+ }
+ $videos = collect([$video]);
+ } elseif ($this->option('all')) {
+ $videos = Video::where('type', 'music')->get();
+ } else {
+ $this->error('Pass a video id or --all.');
+ return self::FAILURE;
+ }
+
+ $dispatched = 0;
+ foreach ($videos as $video) {
+ $video->loadMissing('audioTracks');
+
+ if ($force || ! is_array($nas->getLyrics($video, null))) {
+ GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
+ $dispatched++;
+ }
+ foreach ($video->audioTracks as $track) {
+ if ($force || ! is_array($nas->getLyrics($video, $track))) {
+ GenerateLyricsJob::dispatch($video->id, $track->id)->onConnection('database');
+ $dispatched++;
+ }
+ }
+ $this->line("Queued lyrics for #{$video->id} — {$video->title}");
+ }
+
+ $this->info("Dispatched {$dispatched} lyrics job(s) to the video-processing queue.");
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/MigrateStorageLayout.php b/app/Console/Commands/MigrateStorageLayout.php
new file mode 100644
index 0000000..d5bcc8f
--- /dev/null
+++ b/app/Console/Commands/MigrateStorageLayout.php
@@ -0,0 +1,241 @@
+option('force');
+ $only = $this->option('video') ? (int) $this->option('video') : null;
+
+ $this->info($apply ? '→ APPLY mode (files and DB will be modified)' : '→ DRY-RUN (no changes will be made)');
+
+ $q = Video::query()->with(['user', 'audioTracks', 'slides']);
+ if ($only) $q->where('id', $only);
+ $videos = $q->orderBy('id')->get();
+
+ $this->info("Found {$videos->count()} video(s) to inspect.");
+
+ $stats = ['skipped' => 0, 'planned' => 0, 'applied' => 0, 'errors' => 0];
+
+ foreach ($videos as $video) {
+ try {
+ $plan = $this->planVideo($video, $nas);
+ if ($plan === null) { $stats['skipped']++; continue; }
+
+ $this->line("");
+ $this->info("Video #{$video->id} ({$video->type}): {$video->title}");
+ foreach ($plan as $move) {
+ $this->line(" {$move['from']} → {$move['to']}");
+ }
+ $stats['planned']++;
+
+ if ($apply) {
+ $this->applyPlan($video, $plan, $nas);
+ $stats['applied']++;
+ }
+ } catch (\Throwable $e) {
+ $stats['errors']++;
+ $this->error("Video #{$video->id}: " . $e->getMessage());
+ }
+ }
+
+ $this->line("");
+ $this->info("Done. " . json_encode($stats));
+ return self::SUCCESS;
+ }
+
+ /**
+ * Build the move list for one video. Returns null if it's already in the
+ * target layout. Each plan entry is ['from' => oldPath, 'to' => newPath,
+ * 'kind' => 'video'|'audio'|'slide'|'thumb', 'model' => modelOrNull,
+ * 'slide_field' => 'filename' | 'path' | 'thumbnail'].
+ */
+ private function planVideo(Video $video, NasSyncService $nas): ?array
+ {
+ $path = (string) $video->path;
+ if (! str_starts_with($path, 'users/')) {
+ return null; // unorganised — outside this migration's scope
+ }
+ $segs = explode('/', $path);
+ if (count($segs) < 4) return null;
+
+ $expectedTypeFolder = $nas->typeFolder($video);
+ $currentTypeFolder = $segs[2] ?? null;
+ $isMusic = ($video->type === 'music');
+
+ // Already in target layout? Music: tracks/{lang-id}/audio.{ext}. Others: video.{ext}.
+ $inTracks = ($segs[4] ?? null) === 'tracks';
+ $canonicalPrimary = $isMusic ? 'audio' : 'video';
+ $endsCanonical = preg_match("#/{$canonicalPrimary}\\.[a-z0-9]+$#i", $path) === 1;
+ if ($currentTypeFolder === $expectedTypeFolder
+ && (! $isMusic || $inTracks)
+ && $endsCanonical) {
+ return null; // already migrated
+ }
+
+ // Compute new song/video root: users/{slug}/{type-folder}/{slug}
+ $userSlug = $segs[1];
+ $videoSlug = $isMusic ? ($segs[3] ?? null) : ($segs[3] ?? null);
+ if (! $videoSlug) return null;
+ $newRoot = "users/{$userSlug}/{$expectedTypeFolder}/{$videoSlug}";
+
+ $ext = pathinfo($video->filename ?: $path, PATHINFO_EXTENSION) ?: 'mp4';
+
+ $plan = [];
+
+ // Primary file
+ if ($isMusic) {
+ $primaryFolder = $nas->trackFolderName($video, null);
+ $plan[] = [
+ 'from' => $path,
+ 'to' => "{$newRoot}/tracks/{$primaryFolder}/audio.{$ext}",
+ 'kind' => 'video',
+ 'model' => $video,
+ 'set' => ['path', 'filename'],
+ 'new_filename'=> "audio.{$ext}",
+ ];
+ } else {
+ $plan[] = [
+ 'from' => $path,
+ 'to' => "{$newRoot}/video.{$ext}",
+ 'kind' => 'video',
+ 'model' => $video,
+ 'set' => ['path', 'filename'],
+ 'new_filename'=> "video.{$ext}",
+ ];
+ }
+
+ // Thumbnail
+ if ($video->thumbnail && str_starts_with($video->thumbnail, 'users/')) {
+ $thumbExt = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'webp';
+ $thumbDir = $isMusic
+ ? "{$newRoot}/tracks/" . $nas->trackFolderName($video, null)
+ : $newRoot;
+ $plan[] = [
+ 'from' => $video->thumbnail,
+ 'to' => "{$thumbDir}/thumb.{$thumbExt}",
+ 'kind' => 'thumb',
+ 'model' => $video,
+ 'set' => ['thumbnail'],
+ ];
+ }
+
+ // Extra audio tracks (music only)
+ if ($isMusic) {
+ foreach ($video->audioTracks as $track) {
+ if (! str_starts_with((string) $track->path, 'users/')) continue;
+ $tExt = pathinfo($track->filename ?: $track->path, PATHINFO_EXTENSION) ?: 'mp3';
+ $trackFolder = $nas->trackFolderName($video, $track);
+ $plan[] = [
+ 'from' => $track->path,
+ 'to' => "{$newRoot}/tracks/{$trackFolder}/audio.{$tExt}",
+ 'kind' => 'audio',
+ 'model' => $track,
+ 'set' => ['path', 'filename'],
+ 'new_filename'=> "audio.{$tExt}",
+ ];
+ }
+
+ // Slides (music only) — owners may be NULL (primary), or any track id
+ foreach ($video->slides as $slide) {
+ if (! str_starts_with((string) $slide->filename, 'users/')) continue;
+ $sExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
+ $ownerTrack = $slide->audio_track_id
+ ? $video->audioTracks->firstWhere('id', $slide->audio_track_id)
+ : null;
+ $folder = $nas->trackFolderName($video, $ownerTrack);
+ $plan[] = [
+ 'from' => $slide->filename,
+ 'to' => "{$newRoot}/tracks/{$folder}/slides/{$slide->position}.{$sExt}",
+ 'kind' => 'slide',
+ 'model' => $slide,
+ 'set' => ['filename'],
+ ];
+ }
+ }
+
+ return $plan;
+ }
+
+ /**
+ * Execute one video's plan. On NAS: copy then delete (smbclient has no rename).
+ * On local: rename(). Either way, DB columns are updated after the move succeeds.
+ */
+ private function applyPlan(Video $video, array $plan, NasSyncService $nas): void
+ {
+ $nasOn = $nas->isEnabled();
+
+ foreach ($plan as $move) {
+ // Ensure target dir exists
+ $targetDir = dirname($move['to']);
+ if ($nasOn) {
+ $nas->mkdirp($targetDir);
+ }
+ @mkdir(storage_path('app/' . $targetDir), 0755, true);
+
+ // Move on local
+ $localFrom = storage_path('app/' . $move['from']);
+ $localTo = storage_path('app/' . $move['to']);
+ if (is_file($localFrom)) {
+ @rename($localFrom, $localTo);
+ }
+
+ // Move on NAS via copy+delete
+ if ($nasOn) {
+ $tmp = tempnam(sys_get_temp_dir(), 'mig_');
+ if ($nas->getContent($move['from']) !== null) {
+ // small file like .json — already cached as content; round-trip
+ }
+ // For binary files, pull → push → delete-source
+ $localCache = storage_path('app/' . $move['from']);
+ if (! is_file($localCache)) {
+ $nas->ensureLocalAsset($localCache, $move['from']);
+ }
+ if (is_file($localCache)) {
+ if ($nas->putFile($localCache, $move['to'])) {
+ $nas->deleteFile($move['from']);
+ }
+ }
+ @unlink($tmp);
+ }
+
+ // Update DB
+ $model = $move['model'];
+ $updates = [];
+ foreach ($move['set'] as $col) {
+ if ($col === 'path' || $col === 'thumbnail' || $col === 'filename') {
+ $updates[$col] = ($col === 'filename' && isset($move['new_filename']))
+ ? $move['new_filename']
+ : $move['to'];
+ }
+ }
+ // VideoSlide stores the full path under `filename`
+ if ($model instanceof VideoSlide) {
+ $model->update(['filename' => $move['to']]);
+ } else {
+ $model->update($updates);
+ }
+ }
+ }
+}
diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php
index 73aa7f5..e035875 100644
--- a/app/Http/Controllers/PlaylistController.php
+++ b/app/Http/Controllers/PlaylistController.php
@@ -28,9 +28,8 @@ class PlaylistController extends Controller
}
// View a single playlist
- public function show(Playlist $playlist)
+ public function show(Request $request, Playlist $playlist)
{
- // Check if user can view this playlist
if (! $playlist->canView(Auth::user())) {
abort(404, 'Playlist not found');
}
@@ -38,11 +37,17 @@ class PlaylistController extends Controller
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
+ // Count this visit (deduped per device) after the response is sent so
+ // the page render isn't blocked by the EXISTS / INSERT / UPDATE round-trip.
+ dispatch(function () use ($playlist, $request) {
+ $playlist->bumpViewIfNew($request);
+ })->afterResponse();
+
return view('playlists.show', compact('playlist', 'videos'));
}
// View playlist via unguessable share token (unlisted playlists)
- public function showByToken(string $token)
+ public function showByToken(Request $request, string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
@@ -53,6 +58,10 @@ class PlaylistController extends Controller
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
+ dispatch(function () use ($playlist, $request) {
+ $playlist->bumpViewIfNew($request);
+ })->afterResponse();
+
return view('playlists.show', compact('playlist', 'videos'));
}
@@ -137,6 +146,11 @@ class PlaylistController extends Controller
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
+ // Human share-link click counts as a playlist view (deduped per device).
+ dispatch(function () use ($playlist, $request) {
+ $playlist->bumpViewIfNew($request);
+ })->afterResponse();
+
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@@ -336,6 +350,18 @@ class PlaylistController extends Controller
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
}
+ /**
+ * Body-based mirror of removeVideo: looks up the Video by raw numeric id
+ * from the request body. Lets callers (e.g. the add-to-playlist modal) drive
+ * the toggle without having to know the encoded route key for Video.
+ */
+ public function removeVideoByBody(Request $request, Playlist $playlist)
+ {
+ $request->validate(['video_id' => 'required|exists:videos,id']);
+ $video = Video::findOrFail($request->video_id);
+ return $this->removeVideo($request, $playlist, $video);
+ }
+
// Remove video from playlist
public function removeVideo(Request $request, Playlist $playlist, Video $video)
{
diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php
index 88f18cb..36eb2fa 100644
--- a/app/Http/Controllers/SuperAdminController.php
+++ b/app/Http/Controllers/SuperAdminController.php
@@ -846,48 +846,204 @@ class SuperAdminController extends Controller
public function settings()
{
$settings = [
- 'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
- 'gpu_device' => Setting::get('gpu_device', '0'),
- 'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
- 'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
- 'gpu_preset' => Setting::get('gpu_preset', 'p4'),
- 'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
- 'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
+ 'llm_enabled' => Setting::get('llm_enabled', 'false'),
+ 'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
+ 'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
+ 'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
+ 'llm_active_id' => (string) Setting::get('llm_active_id', ''),
+ ];
+
+ return view('admin.settings', compact('settings'));
+ }
+
+ /**
+ * Settings save handler — accepts partial submissions from any of the
+ * separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
+ * that appear in the request.
+ */
+ public function updateSettings(Request $request)
+ {
+ // ── GPU section ──────────────────────────────────────────────────────
+ if ($request->has('gpu_enabled')) {
+ $request->validate([
+ 'gpu_enabled' => 'required|in:true,false',
+ 'gpu_device' => 'required|integer|min:0|max:15',
+ 'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
+ 'gpu_hwaccel' => 'required|in:cuda,none',
+ 'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
+ 'ffmpeg_binary' => 'required|string|max:255',
+ ]);
+
+ $binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
+ if (! file_exists($binary) || ! is_executable($binary)) {
+ return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
+ }
+
+ Setting::set('gpu_enabled', $request->gpu_enabled);
+ Setting::set('gpu_device', (string) $request->gpu_device);
+ Setting::set('gpu_encoder', $request->gpu_encoder);
+ Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
+ Setting::set('gpu_preset', $request->gpu_preset);
+ Setting::set('ffmpeg_binary', $binary);
+ Setting::flushGpuProbe();
+ }
+
+ // ── Lyrics pipeline section ──────────────────────────────────────────
+ if ($request->has('lyrics_section')) {
+ foreach ([
+ 'lyrics_enabled', // master switch
+ 'lyrics_use_description', // align to description text
+ 'lyrics_vad_enabled', // Silero VAD filter
+ 'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
+ 'lyrics_demucs_enabled', // vocal isolation (Demucs)
+ 'lyrics_llm_decorate', // post-bake emojis via LLM
+ ] as $k) {
+ Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
+ }
+ }
+
+ // ── AI / LLM section ─────────────────────────────────────────────────
+ if ($request->has('llm_section')) {
+ Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
+ Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
+ Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
+ $this->saveLlmProviders($request);
+ }
+
+ return back()->with('success', 'Settings saved.');
+ }
+
+ /**
+ * Probe an LLM provider endpoint: verify the connection and list
+ * available models. Used by the AI / LLM settings page.
+ *
+ * Accepts kind / endpoint / api_key from the form, plus an optional
+ * provider id so we can fall back to the saved key when the admin
+ * left the password field blank (placeholder ••••••••).
+ */
+ public function llmProviderTest(Request $request)
+ {
+ $kind = (string) $request->input('kind', 'ollama');
+ $endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
+ $endpoint = rtrim($endpoint, '/');
+ $apiKey = (string) $request->input('api_key', '');
+ $id = (string) $request->input('id', '');
+
+ if ($apiKey === '' && $id !== '') {
+ $providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
+ foreach ($providers as $p) {
+ if (($p['id'] ?? '') === $id) {
+ $apiKey = (string) ($p['api_key'] ?? '');
+ break;
+ }
+ }
+ }
+
+ if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
+ return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
+ }
+
+ if ($kind !== 'ollama' && $apiKey === '') {
+ return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
+ }
+
+ try {
+ $models = match ($kind) {
+ 'ollama' => $this->fetchOllamaModels($endpoint),
+ 'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
+ 'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
+ };
+ } catch (\Throwable $e) {
+ return response()->json(['ok' => false, 'message' => $e->getMessage()]);
+ }
+
+ sort($models, SORT_NATURAL | SORT_FLAG_CASE);
+ return response()->json([
+ 'ok' => true,
+ 'count' => count($models),
+ 'models' => $models,
+ ]);
+ }
+
+ private function fetchOllamaModels(string $endpoint): array
+ {
+ $resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
+ if (! $resp->successful()) {
+ throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
+ }
+ $j = $resp->json();
+ return array_values(array_filter(array_map(
+ fn ($m) => (string) ($m['name'] ?? ''),
+ $j['models'] ?? []
+ )));
+ }
+
+ private function fetchAnthropicModels(string $endpoint, string $apiKey): array
+ {
+ $resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
+ 'x-api-key' => $apiKey,
+ 'anthropic-version' => '2023-06-01',
+ ])->get($endpoint . '/v1/models');
+ if (! $resp->successful()) {
+ $body = $resp->json();
+ throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
+ }
+ $j = $resp->json();
+ return array_values(array_filter(array_map(
+ fn ($m) => (string) ($m['id'] ?? ''),
+ $j['data'] ?? []
+ )));
+ }
+
+ private function fetchOpenAIModels(string $endpoint, string $apiKey): array
+ {
+ $resp = \Illuminate\Support\Facades\Http::timeout(15)
+ ->withToken($apiKey)->acceptJson()
+ ->get($endpoint . '/v1/models');
+ if (! $resp->successful()) {
+ $body = $resp->json();
+ throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
+ }
+ $j = $resp->json();
+ return array_values(array_filter(array_map(
+ fn ($m) => (string) ($m['id'] ?? ''),
+ $j['data'] ?? []
+ )));
+ }
+
+ public function lyrics()
+ {
+ $settings = [
+ 'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
+ 'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
+ 'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
+ 'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
+ 'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
+ 'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
+ ];
+ return view('admin.lyrics', compact('settings'));
+ }
+
+ public function gpu()
+ {
+ $settings = [
+ 'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
+ 'gpu_device' => Setting::get('gpu_device', '0'),
+ 'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
+ 'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
+ 'gpu_preset' => Setting::get('gpu_preset', 'p4'),
+ 'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
- return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
+ return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
}
- public function updateSettings(Request $request)
+ public function backup()
{
- $request->validate([
- 'gpu_enabled' => 'required|in:true,false',
- 'gpu_device' => 'required|integer|min:0|max:15',
- 'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
- 'gpu_hwaccel' => 'required|in:cuda,none',
- 'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
- 'ffmpeg_binary' => 'required|string|max:255',
- ]);
-
- $binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
- if (! file_exists($binary) || ! is_executable($binary)) {
- return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
- }
-
- Setting::set('gpu_enabled', $request->gpu_enabled);
- Setting::set('gpu_device', (string) $request->gpu_device);
- Setting::set('gpu_encoder', $request->gpu_encoder);
- Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
- Setting::set('gpu_preset', $request->gpu_preset);
- Setting::set('ffmpeg_binary', $binary);
-
- // GPU config changed — drop the cached health-check so the next encode re-probes.
- Setting::flushGpuProbe();
-
- return back()->with('success', 'Settings saved.');
+ return view('admin.backup');
}
public function detectGpu()
@@ -895,6 +1051,67 @@ class SuperAdminController extends Controller
return response()->json(['gpus' => $this->probeGpus()]);
}
+ /**
+ * Persist the LLM provider list from the multi-provider form. Each row
+ * carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
+ * api_key. An empty api_key means "keep the previously stored value" so the
+ * admin doesn't have to retype it on every save.
+ */
+ private function saveLlmProviders(Request $request): void
+ {
+ $existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
+ ->keyBy(fn ($p) => $p['id'] ?? '');
+
+ $kinds = ['ollama', 'anthropic', 'openai'];
+ $rows = (array) $request->input('providers', []);
+ $out = [];
+
+ foreach ($rows as $row) {
+ $name = trim((string) ($row['name'] ?? ''));
+ $kind = (string) ($row['kind'] ?? 'ollama');
+ if (! in_array($kind, $kinds, true)) $kind = 'ollama';
+ if ($name === '') continue;
+
+ $id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
+ $endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
+ $model = trim((string) ($row['model'] ?? ''));
+ $apiKeyIn = (string) ($row['api_key'] ?? '');
+ // Blank input → keep the previously-stored key for this id (admin
+ // didn't retype it). Non-blank → use the new value verbatim.
+ $apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
+
+ $out[] = [
+ 'id' => $id,
+ 'name' => $name,
+ 'kind' => $kind,
+ 'endpoint' => $endpoint,
+ 'model' => $model,
+ 'api_key' => $apiKey,
+ ];
+ }
+
+ Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
+
+ $activeId = trim((string) $request->input('llm_active_id', ''));
+ $validIds = array_column($out, 'id');
+ if ($activeId !== '' && in_array($activeId, $validIds, true)) {
+ Setting::set('llm_active_id', $activeId);
+ } elseif (count($validIds) === 1) {
+ Setting::set('llm_active_id', $validIds[0]);
+ } elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
+ Setting::set('llm_active_id', '');
+ }
+ }
+
+ private static function defaultEndpoint(string $kind): string
+ {
+ return match ($kind) {
+ 'anthropic' => 'https://api.anthropic.com',
+ 'openai' => 'https://api.openai.com',
+ default => 'http://localhost:11434',
+ };
+ }
+
private function probeGpus(): array
{
$gpus = [];
@@ -935,7 +1152,10 @@ class SuperAdminController extends Controller
public function nasStorage()
{
$nodes = config('nas-file-manager.schema', []);
- return view('admin.nas-storage', compact('nodes'));
+ $settings = [
+ 'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
+ ];
+ return view('admin.nas-storage', compact('nodes', 'settings'));
}
public function nasDelete(Request $request)
diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php
index b7e8a57..cc0446d 100644
--- a/app/Http/Controllers/VideoController.php
+++ b/app/Http/Controllers/VideoController.php
@@ -26,7 +26,7 @@ class VideoController extends Controller
{
public function __construct()
{
- $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']);
+ $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
}
public function index()
@@ -161,6 +161,12 @@ class VideoController extends Controller
'extra_track_titles.*' => 'nullable|string|max:255',
'extra_track_descriptions' => 'nullable|array',
'extra_track_descriptions.*'=> 'nullable|string',
+ // Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
+ // If absent for a given index, the track inherits the primary's slides
+ // at render time via Video::slidesForTrack().
+ 'extra_track_slides' => 'nullable|array',
+ 'extra_track_slides.*' => 'nullable|array',
+ 'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$videoFile = $request->file('video');
@@ -332,6 +338,7 @@ class VideoController extends Controller
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
+ $trackSlides = $request->file('extra_track_slides') ?: [];
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
@@ -354,19 +361,20 @@ class VideoController extends Controller
if ($nas->isEnabled()) {
try {
- $videoDir = $nas->resolveVideoDir($video);
- $nas->mkdirp($videoDir);
- $trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
+ // Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
+ $trackDir = $nas->trackDir($video, $track);
+ $nas->mkdirp($trackDir);
+ $canonical = "audio.{$ext}";
+ $nasPath = "{$trackDir}/{$canonical}";
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
- $nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
- 'filename' => $trackName,
+ 'filename' => $canonical,
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
@@ -376,6 +384,23 @@ class VideoController extends Controller
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
+
+ // ── Optional per-track slides ──────────────────────────────
+ // The track only owns slides that were uploaded for it. If none
+ // were uploaded, the player falls back to the primary's at render
+ // time via Video::slidesForTrack — no row needed here.
+ $files = $trackSlides[$i] ?? null;
+ if (is_array($files) && count($files) > 0) {
+ $this->storeTrackSlides($video, $track, $files, $nas);
+ }
+ }
+ }
+
+ // ── Synced lyrics generation (audio/music uploads only) ───────────────
+ if ($isAudioUpload) {
+ \App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
+ foreach ($video->audioTracks()->pluck('id') as $tid) {
+ \App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
}
}
@@ -544,9 +569,19 @@ class VideoController extends Controller
if ($playlistParam) {
$playlist = Playlist::where('share_token', $playlistParam)->first();
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
- $nextVideo = $playlist->getNextVideo($video);
- $previousVideo = $playlist->getPreviousVideo($video);
- $playlistVideos = $playlist->videos;
+ // Load the videos ONCE with their owners eager-loaded, then
+ // compute prev/next in PHP. The old code fired 4+ separate
+ // queries for prev/next/list — the sidebar lag the user
+ // reported was almost entirely those extra round-trips.
+ $playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
+ [$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
+
+ // Count the playlist view (deduped per device, 1-hour window)
+ // after the response is flushed so we don't pay the round-trip
+ // on the hot path.
+ dispatch(function () use ($playlist, $request) {
+ $playlist->bumpViewIfNew($request);
+ })->afterResponse();
}
}
@@ -579,9 +614,16 @@ class VideoController extends Controller
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
- $slides = $video->slides->count() > 1
- ? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
- : [];
+ // Per-track slide map (key "0" = primary). Each entry already has the
+ // sharing fallback applied by Video::slidesForTrack — a track without its
+ // own slides borrows the primary's (or a sibling's) automatically.
+ $slideMap = ['0' => $video->slidesForTrack(null)
+ ->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
+ foreach ($video->audioTracks as $_t) {
+ $slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
+ ->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
+ }
+ $slides = $slideMap['0'];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
@@ -595,6 +637,14 @@ class VideoController extends Controller
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all();
+ // Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
+ // Local mirror only — must not block this hot path on NAS I/O.
+ $nasLyrics = app(\App\Services\NasSyncService::class);
+ $lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
+ foreach ($video->audioTracks as $t) {
+ $lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
+ }
+
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
@@ -604,6 +654,7 @@ class VideoController extends Controller
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
+ 'slide_map' => $slideMap,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
@@ -612,9 +663,212 @@ class VideoController extends Controller
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
+ 'lyrics' => $lyricsMap,
]);
}
+ /**
+ * Owner-triggered lyrics generation for the current audio track (?track={id},
+ * 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
+ * polls player-data and shows the lyrics once the file lands.
+ */
+ public function generateLyrics(Request $request, Video $video)
+ {
+ if (Auth::id() !== $video->user_id) {
+ abort(403);
+ }
+ if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
+ return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
+ }
+ if (! $this->isAudioOnlyFile($video)) {
+ return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
+ }
+
+ $trackId = (int) $request->input('track', 0);
+ if ($trackId) {
+ $track = $video->audioTracks()->find($trackId);
+ if (! $track) {
+ return response()->json(['error' => 'Track not found.'], 404);
+ }
+ }
+
+ \App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
+
+ return response()->json(['status' => 'queued']);
+ }
+
+ /**
+ * Owner-triggered delete of the saved lyrics for a track. Wipes the local
+ * mirror + the NAS copy and removes any in-flight progress / temp files,
+ * so the next Generate produces a fresh result.
+ */
+ public function deleteLyrics(Request $request, Video $video)
+ {
+ if (Auth::id() !== $video->user_id) {
+ abort(403);
+ }
+
+ $trackId = (int) $request->input('track', 0);
+ $track = null;
+ if ($trackId) {
+ $track = $video->audioTracks()->find($trackId);
+ if (! $track) {
+ return response()->json(['error' => 'Track not found.'], 404);
+ }
+ }
+
+ app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
+
+ // Clear any in-flight progress / temp artifacts so a queued job that
+ // fires later can't repopulate stale output.
+ @unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
+ @unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
+
+ return response()->json(['status' => 'deleted']);
+ }
+
+ /**
+ * Live progress for an in-flight lyrics generation, driving the player's
+ * progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
+ */
+ public function lyricsProgress(Request $request, Video $video)
+ {
+ if (! $video->canView(Auth::user())) {
+ abort(403);
+ }
+
+ $trackId = (int) $request->input('track', 0);
+ $track = null;
+ if ($trackId) {
+ $track = $video->audioTracks()->find($trackId);
+ if (! $track) return response()->json(['status' => 'none']);
+ }
+
+ $data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
+ if (is_array($data)) {
+ $st = $data['status'] ?? 'ready';
+ if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
+ if ($st === 'failed') return response()->json(['status' => 'failed']);
+ }
+
+ // Live percentage from the pipeline's progress file.
+ $progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
+ if (is_file($progFile)) {
+ $p = json_decode((string) file_get_contents($progFile), true);
+ if (is_array($p)) {
+ return response()->json([
+ 'status' => 'processing',
+ 'pct' => (int) ($p['pct'] ?? 1),
+ 'stage' => $p['stage'] ?? 'Working',
+ ]);
+ }
+ }
+
+ // A 'processing' lyrics marker but no progress file yet → just queued.
+ if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
+
+ return response()->json(['status' => 'none']);
+ }
+
+ /**
+ * Owner-edited lyrics save. Receives the (possibly corrected) lines for a
+ * track; preserves precise word timing for lines that weren't changed and
+ * redistributes timing evenly across the new words for edited lines.
+ */
+ public function saveLyrics(Request $request, Video $video)
+ {
+ if (Auth::id() !== $video->user_id) {
+ abort(403);
+ }
+
+ $trackId = (int) $request->input('track', 0);
+ $track = null;
+ if ($trackId) {
+ $track = $video->audioTracks()->find($trackId);
+ if (! $track) return response()->json(['error' => 'Track not found.'], 404);
+ }
+
+ $nas = app(\App\Services\NasSyncService::class);
+ $existing = $nas->getLocalLyrics($video, $track) ?: [];
+ $spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
+
+ $inLines = $request->input('lines', []);
+ if (! is_array($inLines)) $inLines = [];
+
+ $out = [];
+ foreach ($inLines as $ln) {
+ $text = trim(strip_tags((string) ($ln['text'] ?? '')));
+ if ($text === '' || mb_strlen($text) > 1000) {
+ if ($text === '') continue;
+ $text = mb_substr($text, 0, 1000);
+ }
+ $start = round((float) ($ln['start'] ?? 0), 3);
+ $end = round((float) ($ln['end'] ?? $start), 3);
+ $lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
+ $isSpaceless = in_array($lang, $spaceless, true);
+
+ // Keep original word timings if the text wasn't changed; otherwise
+ // redistribute the line's span evenly across the new tokens.
+ $origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
+ $sep = $isSpaceless ? '' : ' ';
+ $joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
+ $unchanged = $origWords
+ && preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
+
+ if ($unchanged) {
+ $words = array_map(fn ($w) => [
+ 'start' => round((float) ($w['start'] ?? $start), 3),
+ 'end' => round((float) ($w['end'] ?? $end), 3),
+ 'text' => (string) ($w['text'] ?? ''),
+ ], $origWords);
+ } else {
+ $words = $this->redistributeWords($start, $end, $text, $isSpaceless);
+ }
+
+ $out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
+ if (count($out) >= 1000) break;
+ }
+
+ usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
+
+ $nas->putLyrics($video, $track, [
+ 'version' => 1,
+ 'status' => 'ready',
+ 'source' => 'edited',
+ 'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
+ 'multilingual' => $existing['multilingual'] ?? false,
+ 'lines' => $out,
+ 'generated_at' => now()->toIso8601String(),
+ ]);
+
+ return response()->json(['status' => 'ok', 'lines' => count($out)]);
+ }
+
+ /** Evenly distribute a line's [start,end] across its words (used for edited lines). */
+ private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
+ {
+ $text = trim($text);
+ if ($text === '' || $end <= $start) {
+ return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
+ }
+ $tokens = $spaceless
+ ? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
+ : preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
+ $tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
+ $n = count($tokens);
+ if ($n === 0) return [];
+ $slice = ($end - $start) / $n;
+ $words = [];
+ foreach ($tokens as $i => $tok) {
+ $words[] = [
+ 'start' => round($start + $i * $slice, 3),
+ 'end' => round($start + ($i + 1) * $slice, 3),
+ 'text' => $tok,
+ ];
+ }
+ return $words;
+ }
+
public function matchData(Video $video)
{
if (! $video->canView(Auth::user())) {
@@ -639,8 +893,9 @@ class VideoController extends Controller
}
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
- 'id' => $s->id,
- 'url' => $s->url,
+ 'id' => $s->id,
+ 'url' => $s->url,
+ 'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
@@ -1485,21 +1740,93 @@ class VideoController extends Controller
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{
try {
- // All tracks (primary + secondary) live directly in the song's own folder —
- // never a separate tracks/ subfolder — with a unique, lowercase name.
- $localDir = $nas->localVideoDir($video);
- @mkdir($localDir, 0755, true);
- $base = basename($localDir);
- $trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
- $trackFile->move($localDir, $trackName);
- $userSlug = $nas->userSlug($video->user);
- $relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
- $track->update(['path' => $relPath, 'filename' => $trackName]);
+ // Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
+ // (storeTrackLocally is only reached for music since extra tracks
+ // only exist on music videos.)
+ $songLocalDir = $nas->localVideoDir($video);
+ $trackFolder = $nas->trackFolderName($video, $track);
+ $trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
+ @mkdir($trackDirAbs, 0755, true);
+
+ $canonical = "audio.{$ext}";
+ $trackFile->move($trackDirAbs, $canonical);
+
+ // Build the NAS-relative path from the song's relative path so it
+ // works on both local-only and NAS-enabled setups.
+ $songRel = $this->relFromStoragePath($songLocalDir);
+ $relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
+
+ $track->update(['path' => $relPath, 'filename' => $canonical]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
+ /** Convert an absolute storage_path() value back to a storage-relative path. */
+ private function relFromStoragePath(string $abs): string
+ {
+ $prefix = storage_path('app/');
+ if (str_starts_with($abs, $prefix)) {
+ return substr($abs, strlen($prefix));
+ }
+ return $abs;
+ }
+
+ /**
+ * Persist per-track slides — the new track owns them via audio_track_id.
+ * Filename scheme keeps tracks from colliding in the shared slides/ folder:
+ * slides/track-{trackId}-{position}.{ext}
+ * Primary slides keep the legacy slides/{position}.{ext} scheme.
+ */
+ private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
+ {
+ // Per-track slides live in the track's own folder:
+ // tracks/{lang-id}/slides/{position}.{ext}
+ // Filenames are canonical (just {position}.{ext}) because the folder
+ // already disambiguates by track.
+ $nasEnabled = $nas->isEnabled();
+ $songLocal = $nas->localVideoDir($video);
+ $songRel = $this->relFromStoragePath($songLocal);
+ $trackFold = $nas->trackFolderName($video, $track);
+ $nasTrackDir = "{$songRel}/tracks/{$trackFold}";
+ $localTrackDir = "{$songLocal}/tracks/{$trackFold}";
+
+ if ($nasEnabled) {
+ try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
+ } else {
+ @mkdir("{$localTrackDir}/slides", 0755, true);
+ }
+
+ foreach ($files as $pos => $file) {
+ if (! $file || ! $file->isValid()) continue;
+ $ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
+ $name = "{$pos}.{$ext}";
+ $slide = VideoSlide::create([
+ 'video_id' => $video->id,
+ 'audio_track_id' => $track->id,
+ 'filename' => '__pending__',
+ 'position' => $pos,
+ ]);
+
+ $tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
+ $tempAbs = storage_path('app/' . $tempPath);
+
+ if ($nasEnabled) {
+ $nasPath = "{$nasTrackDir}/slides/{$name}";
+ if ($nas->putFile($tempAbs, $nasPath)) {
+ @unlink($tempAbs);
+ $slide->update(['filename' => $nasPath]);
+ continue;
+ }
+ // Fall through to local on NAS failure
+ }
+
+ @mkdir("{$localTrackDir}/slides", 0755, true);
+ @rename($tempAbs, "{$localTrackDir}/slides/{$name}");
+ $slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
+ }
+ }
+
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
@@ -1602,10 +1929,11 @@ class VideoController extends Controller
// Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
+ $lyrics = request()->boolean('lyrics');
- // Any non-primary or visualizer variant is served straight off disk (no DB column).
- if ($viz || $trackId) {
- $cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
+ // Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
+ if ($viz || $trackId || $lyrics) {
+ $cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
@@ -1707,7 +2035,8 @@ class VideoController extends Controller
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
- $suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
+ $lyrics = $request->boolean('lyrics');
+ $suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
@@ -1731,7 +2060,7 @@ class VideoController extends Controller
return response()->json(['error' => 'Audio file not found'], 404);
}
- $outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
+ $outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
@@ -1772,12 +2101,37 @@ class VideoController extends Controller
return response()->json(['error' => 'Cannot probe audio duration'], 500);
}
- $slides = $video->slides()->orderBy('position')->get();
+ // Use the slides for the track being rendered, applying the per-track
+ // sharing fallback (own → primary → sibling). Ensures the download .mp4
+ // matches the slideshow the listener saw in the player.
+ $video->loadMissing('slides');
+ $slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
+ // ── Optional burned-in lyrics (ASS via libass) ───────────────────────
+ // When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
+ // track and weave it into the final video output. $voutLabel/$assArg/
+ // $assTail let each render branch inject the burn uniformly.
+ $voutLabel = '[vout]';
+ $assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
+ $assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
+ if ($lyrics) {
+ $lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
+ $lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
+ if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
+ $assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
+ if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
+ $assFilter = 'ass=' . $assPath;
+ $assArg = ',' . $assFilter;
+ $assTail = ';[vout]' . $assFilter . '[vsub]';
+ $voutLabel = '[vsub]';
+ }
+ }
+ }
+
@unlink($progressFile);
@unlink($pidFile);
@@ -1850,10 +2204,10 @@ class VideoController extends Controller
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
- $fc = implode(';', $fcParts);
+ $fc = implode(';', $fcParts) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
- . ' -map [vout] -map ' . $audioIdx . ':a'
+ . ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@@ -1881,10 +2235,10 @@ class VideoController extends Controller
$prev = $outLabel;
}
- $fc = implode(';', array_merge($scaleFc, $xfadeFc));
+ $fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
- . ' -map [vout] -map ' . $n . ':a'
+ . ' -map ' . $voutLabel . ' -map ' . $n . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@@ -1894,7 +2248,7 @@ class VideoController extends Controller
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
- . ' -vf ' . escapeshellarg($scale)
+ . ' -vf ' . escapeshellarg($scale . $assArg)
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
} else {
@@ -1902,6 +2256,7 @@ class VideoController extends Controller
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
. ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest'
+ . ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart';
}
@@ -1934,9 +2289,10 @@ class VideoController extends Controller
{
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
- $suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
+ $lyrics = request()->boolean('lyrics');
+ $suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
- $outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
+ $outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
@@ -1955,7 +2311,7 @@ class VideoController extends Controller
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
- if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
+ if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
return response()->json(['percent' => 100, 'status' => 'ready']);
@@ -2089,12 +2445,13 @@ class VideoController extends Controller
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
- private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
+ private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
- . ($viz ? '-viz' : '') . '.mp4';
+ . ($viz ? '-viz' : '')
+ . ($lyrics ? '-lyr' : '') . '.mp4';
}
/**
diff --git a/app/Jobs/DecorateLyricsJob.php b/app/Jobs/DecorateLyricsJob.php
new file mode 100644
index 0000000..f77b189
--- /dev/null
+++ b/app/Jobs/DecorateLyricsJob.php
@@ -0,0 +1,139 @@
+onQueue('video-processing');
+ }
+
+ public function handle(LlmLyricsService $llm, NasSyncService $nas): void
+ {
+ // Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
+ // page) gates this job, and the LLM-service-level switch (AI/LLM page)
+ // gates the LLM call inside it. Either being OFF skips decoration.
+ if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
+ if (! $llm->decorateEnabled()) return;
+
+ $video = Video::find($this->videoId);
+ if (! $video) return;
+
+ $track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
+ if ($this->trackId && ! $track) return;
+
+ $data = $nas->getLyrics($video, $track);
+ if (! is_array($data) || empty($data['lines'])) return;
+ if (($data['status'] ?? null) !== 'ready') return;
+
+ // Decorate only lines that haven't been decorated yet — a re-run fills
+ // gaps cheaply instead of re-stamping the whole song.
+ $texts = [];
+ $indices = [];
+ foreach ($data['lines'] as $i => $ln) {
+ if (! empty($ln['decorated'])) continue;
+ $t = (string) ($ln['text'] ?? '');
+ if (trim($t) === '') continue;
+ $texts[] = $t;
+ $indices[] = $i;
+ }
+ if (! $texts) return;
+
+ try {
+ $decorated = $llm->decorateLines($texts);
+ } catch (\Throwable $e) {
+ Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ ]);
+ return;
+ }
+ if (! $decorated) return;
+
+ $applied = 0;
+ foreach ($decorated as $localIdx => $newText) {
+ if (! isset($indices[$localIdx])) continue;
+ $globalIdx = $indices[$localIdx];
+ $line = &$data['lines'][$globalIdx];
+
+ $line['text'] = $newText;
+ $line['decorated'] = true;
+ // The words array no longer matches the new (emoji-laced) text. We
+ // redistribute the existing [start,end] window evenly across the
+ // new tokens so the karaoke word-highlight still tracks the audio.
+ // Tokens that are pure emoji get the same per-slot timing as words.
+ $lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
+ $line['words'] = $this->redistributeWords(
+ (float) ($line['start'] ?? 0),
+ (float) ($line['end'] ?? 0),
+ $newText, $lang
+ );
+ unset($line);
+ $applied++;
+ }
+
+ if (! $applied) return;
+
+ $data['decorated_at'] = now()->toIso8601String();
+ $nas->putLyrics($video, $track, $data);
+
+ Log::info('DecorateLyricsJob: done', [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ 'decorated' => $applied,
+ ]);
+ }
+
+ /**
+ * Evenly distribute [start,end] across the line's tokens. Words for spaced
+ * languages, characters for spaceless scripts (Thai/CJK/…). Used after
+ * decoration so the karaoke word-highlight still tracks the audio.
+ */
+ private function redistributeWords(float $start, float $end, string $text, string $lang): array
+ {
+ if ($text === '' || $end <= $start) return [];
+ $spaceless = in_array($lang, self::SPACELESS_LANGS, true);
+ $tokens = $spaceless
+ ? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
+ : preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
+ $n = count($tokens ?: []);
+ if ($n === 0) return [];
+ $slot = ($end - $start) / $n;
+ $out = [];
+ foreach ($tokens as $i => $t) {
+ $out[] = [
+ 'start' => round($start + $i * $slot, 3),
+ 'end' => round($start + ($i + 1) * $slot, 3),
+ 'text' => $t,
+ ];
+ }
+ return $out;
+ }
+}
diff --git a/app/Jobs/GenerateLyricsJob.php b/app/Jobs/GenerateLyricsJob.php
new file mode 100644
index 0000000..dc963f6
--- /dev/null
+++ b/app/Jobs/GenerateLyricsJob.php
@@ -0,0 +1,244 @@
+onQueue('video-processing');
+ }
+
+ /** Shared progress-file path (written by the pipeline, read by the status endpoint). */
+ public static function progressPath(int $videoId, ?int $trackId): string
+ {
+ return storage_path('app/tmp/lyrics_prog_' . $videoId . '_' . ($trackId ?? 'primary') . '.json');
+ }
+
+ /** Index of the GPU with the most free memory, or null if it can't be queried. */
+ private function freestGpu(): ?int
+ {
+ $out = []; $code = 1;
+ @exec('nvidia-smi --query-gpu=index,memory.free --format=csv,noheader,nounits 2>/dev/null', $out, $code);
+ if ($code !== 0 || empty($out)) return null;
+ $best = null; $bestFree = -1;
+ foreach ($out as $line) {
+ $parts = array_map('trim', explode(',', $line));
+ if (count($parts) < 2) continue;
+ $idx = (int) $parts[0]; $free = (int) $parts[1];
+ if ($free > $bestFree) { $bestFree = $free; $best = $idx; }
+ }
+ return $best;
+ }
+
+ public function handle(NasSyncService $nas): void
+ {
+ $video = Video::find($this->videoId);
+ if (! $video) return;
+
+ $track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
+ if ($this->trackId && ! $track) return;
+
+ $language = $track ? $track->language : $video->language;
+
+ // Mark as processing so the UI can show a generating state before the file lands.
+ $nas->putLyrics($video, $track, [
+ 'version' => 1,
+ 'status' => 'processing',
+ 'language' => $language,
+ ]);
+
+ // Resolve a readable local copy of the audio (downloads from NAS if needed).
+ $audioPath = $track ? $nas->ensureLocalTrackCopy($track) : $nas->ensureLocalCopy($video);
+ $nasDownloaded = $audioPath && str_starts_with($audioPath, storage_path('app/nas_cache/'))
+ ? $audioPath : null;
+
+ if (! $audioPath || ! file_exists($audioPath)) {
+ Log::error('GenerateLyricsJob: audio file unavailable', [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ ]);
+ $nas->putLyrics($video, $track, [
+ 'version' => 1, 'status' => 'failed', 'language' => $language,
+ 'error' => 'audio file unavailable',
+ ]);
+ return;
+ }
+
+ $python = base_path('ml/venv/bin/python');
+ $script = base_path('ml/transcribe.py');
+ $outTmp = storage_path('app/tmp/lyrics_' . $this->videoId . '_' . ($this->trackId ?? 'primary') . '.json');
+ $progress = self::progressPath($this->videoId, $this->trackId);
+ if (! is_dir(dirname($outTmp))) @mkdir(dirname($outTmp), 0775, true);
+ @file_put_contents($progress, json_encode(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']));
+
+ // Model/weight downloads land in a www-data-writable cache, not root's $HOME.
+ $cacheDir = base_path('ml/cache');
+ if (! is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
+
+ // NOTE: we deliberately do NOT force --language. The stored label is just
+ // metadata and is often wrong (e.g. a Tagalog song mislabeled "en"), which
+ // made WhisperX transcribe the wrong language. Auto-detecting from the
+ // isolated vocals is ground truth; the detected language is saved instead.
+ $args = [$python, $script, '--audio', $audioPath, '--out', $outTmp, '--progress', $progress];
+
+ // Pipeline feature toggles (admin → Lyrics Pipeline). Defaults preserve
+ // current behavior; admin can disable any sub-step that misbehaves.
+ $useDescription = Setting::get('lyrics_use_description', 'true') === 'true';
+ $vadEnabled = Setting::get('lyrics_vad_enabled', 'true') === 'true';
+ $vocalGapFill = Setting::get('lyrics_vocal_region_gapfill', 'true') === 'true';
+ $demucsEnabled = Setting::get('lyrics_demucs_enabled', 'false') === 'true';
+
+ if (! $vadEnabled) $args[] = '--no-vad';
+ if (! $vocalGapFill) $args[] = '--no-vocal-gapfill';
+
+ // If the song's description contains the lyrics (typed by the uploader),
+ // pass them to the pipeline so it ALIGNS those exact lines to the audio
+ // instead of generating noisier text from scratch. Only for the primary
+ // track — extra-language tracks have their own audio and aren't paired
+ // with the description text.
+ $userLyrFile = null;
+ if ($useDescription && ! $this->trackId && $video->description) {
+ // Prefer the deterministic regex parser. It strips emojis line-by-line
+ // without touching the underlying words, so it preserves every
+ // language a multilingual song contains (e.g. an English+Thai song
+ // keeps both halves). The LLM cleaner is only a backup for cases
+ // where the regex returns nothing — we've seen the LLM silently
+ // drop whole verses that happened to be wrapped in emoji decoration.
+ $descLines = \App\Support\LyricsDescriptionParser::extract($video->description);
+ $source = 'regex';
+
+ if (empty($descLines)) {
+ $llm = app(\App\Services\LlmLyricsService::class);
+ if ($llm->cleanLyricsEnabled()) {
+ try {
+ $descLines = $llm->cleanDescription($video->description);
+ $source = 'llm';
+ } catch (\Throwable $e) {
+ Log::warning('LLM clean failed: ' . $e->getMessage());
+ }
+ }
+ }
+
+ if ($descLines) {
+ $userLyrFile = storage_path('app/tmp/userlyr_' . $this->videoId . '.txt');
+ file_put_contents($userLyrFile, implode("\n", $descLines));
+ $args[] = '--user-lyrics';
+ $args[] = $userLyrFile;
+ // With description lyrics, Whisper is only providing word-timing
+ // anchors — its actual transcription text is discarded by the
+ // aligner. Vocal isolation (Demucs) helps transcription QUALITY
+ // but is unnecessary for timing, AND the Demucs→Whisper CUDA-
+ // context handoff has caused intermittent 50% futex deadlocks.
+ // So we skip Demucs in this mode by default; the admin can
+ // re-enable via the Lyrics Pipeline page.
+ $args[] = '--no-demucs';
+ Log::info('GenerateLyricsJob: using description lyrics', [
+ 'video_id' => $this->videoId, 'lines' => count($descLines),
+ 'source' => $source, 'demucs' => false,
+ 'vad' => $vadEnabled, 'vocal_gapfill' => $vocalGapFill,
+ ]);
+ }
+ }
+ // Honor the admin Demucs toggle for tracks WITHOUT description lyrics
+ // (where Whisper's transcription quality actually matters).
+ if (! $userLyrFile && ! $demucsEnabled) {
+ $args[] = '--no-demucs';
+ }
+ if (Setting::gpuUsable()) {
+ // Run on the GPU with the most free VRAM so a busy card never forces an
+ // out-of-memory fall back to slow CPU. With two cards this keeps every
+ // generation on the GPU and fast.
+ $args[] = '--gpu';
+ $args[] = (string) ($this->freestGpu() ?? Setting::gpuDevice());
+ }
+
+ Log::info('GenerateLyricsJob: starting', [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ 'language' => $language, 'gpu' => Setting::gpuUsable(),
+ ]);
+
+ try {
+ $result = Process::timeout($this->timeout)
+ ->env([
+ 'HOME' => $cacheDir,
+ 'XDG_CACHE_HOME' => $cacheDir,
+ 'HF_HOME' => $cacheDir . '/huggingface',
+ 'TORCH_HOME' => $cacheDir . '/torch',
+ // Demucs runs as a subprocess BEFORE faster-whisper is imported.
+ // If OpenMP gets initialised in the parent before that fork, the
+ // post-fork CUDA/ctranslate2 stack can deadlock in futex_wait —
+ // we've seen this hang lyrics jobs at 50% indefinitely. Forcing
+ // single-threaded OpenMP in the parent eliminates the race
+ // (faster-whisper sets its own thread count internally anyway).
+ 'OMP_NUM_THREADS' => '1',
+ 'MKL_NUM_THREADS' => '1',
+ 'OPENBLAS_NUM_THREADS' => '1',
+ ])
+ ->run($args);
+
+ if (! $result->successful() || ! file_exists($outTmp)) {
+ throw new \RuntimeException('transcribe.py failed: ' . substr($result->errorOutput(), -2000));
+ }
+
+ $data = json_decode((string) file_get_contents($outTmp), true);
+ if (! is_array($data) || empty($data['lines'])) {
+ throw new \RuntimeException('transcribe.py produced no lines');
+ }
+
+ $data['status'] = 'ready';
+ $data['generated_at'] = now()->toIso8601String();
+ $data['language'] = $data['language'] ?? $language;
+
+ $nas->putLyrics($video, $track, $data);
+
+ // Decoration is independent of the audio pipeline — kick it off as
+ // its own job so a flaky LLM call can't delay or fail a successful
+ // transcription. Skips itself silently if the decorator is off.
+ DecorateLyricsJob::dispatch($this->videoId, $this->trackId)
+ ->onConnection('database');
+
+ Log::info('GenerateLyricsJob: done', [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ 'lines' => count($data['lines']),
+ ]);
+ } catch (\Throwable $e) {
+ Log::error('GenerateLyricsJob failed: ' . $e->getMessage(), [
+ 'video_id' => $this->videoId, 'track_id' => $this->trackId,
+ ]);
+ $nas->putLyrics($video, $track, [
+ 'version' => 1, 'status' => 'failed', 'language' => $language,
+ 'error' => $e->getMessage(),
+ ]);
+ } finally {
+ @unlink($outTmp);
+ @unlink($progress);
+ if ($userLyrFile) @unlink($userLyrFile);
+ if ($nasDownloaded) @unlink($nasDownloaded);
+ }
+ }
+}
diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php
index 021bba2..390664e 100644
--- a/app/Models/Playlist.php
+++ b/app/Models/Playlist.php
@@ -17,10 +17,12 @@ class Playlist extends Model
'visibility',
'is_default',
'share_token',
+ 'view_count',
];
protected $casts = [
'is_default' => 'boolean',
+ 'view_count' => 'integer',
];
// Relationships
@@ -83,12 +85,87 @@ class Playlist extends Model
return "{$minutes}m";
}
- // Get total views of all videos in playlist
+ // Total of every viewer-session aggregated across the playlist's videos.
+ // Kept for the analytics-style "video time watched" metric — for the
+ // playlist's OWN view counter (cards, share link), use $playlist->view_count
+ // which is incremented per-device by bumpViewIfNew().
public function getTotalViewsAttribute()
{
return $this->videos()->get()->sum('view_count');
}
+ /**
+ * Record a playlist view if this viewer hasn't already counted within the
+ * dedup window. Mirrors the video_views pattern: signed-in users dedup by
+ * user_id, guests dedup by device fingerprint (preferred) or device cookie.
+ *
+ * Cheap and idempotent — runs as one EXISTS + (optionally) one INSERT +
+ * one atomic increment, all on indexed columns. Called via
+ * dispatchAfterResponse() so it never adds latency to the page render.
+ */
+ public function bumpViewIfNew(\Illuminate\Http\Request $request): void
+ {
+ $userId = \Illuminate\Support\Facades\Auth::id();
+ $did = $request->cookie('_did');
+ $fp = $request->cookie('_fp');
+ $fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
+
+ // No identifier at all? Skip silently — a unidentifiable request would
+ // count on every refresh and inflate the counter.
+ if (! $userId && ! $did && ! $fp) return;
+
+ $q = \DB::table('playlist_views')
+ ->where('playlist_id', $this->id)
+ ->where('viewed_at', '>', now()->subHour());
+
+ if ($userId) {
+ $q->where('user_id', $userId);
+ } else {
+ $q->whereNull('user_id')->where(function ($w) use ($fp, $did) {
+ if ($fp) $w->orWhere('device_hash', $fp);
+ if ($did) $w->orWhere('device_id', $did);
+ });
+ }
+
+ if ($q->exists()) return;
+
+ $ip = $request->header('CF-Connecting-IP')
+ ?? $request->header('X-Real-IP')
+ ?? $request->ip();
+ $geo = \App\Services\GeoIpService::lookup($ip);
+
+ \DB::table('playlist_views')->insert([
+ 'playlist_id' => $this->id,
+ 'user_id' => $userId,
+ 'device_id' => $did,
+ 'device_hash' => $fp,
+ 'ip_address' => $ip,
+ 'country' => $geo['country'] ?? null,
+ 'country_name' => $geo['country_name'] ?? null,
+ 'user_agent' => substr((string) $request->userAgent(), 0, 512),
+ 'viewed_at' => now(),
+ ]);
+
+ \DB::table('playlists')->where('id', $this->id)->increment('view_count');
+ }
+
+ /**
+ * Compute previous/next videos from an already-loaded ordered collection
+ * (the playlist's videos in position order). Saves the 4+ extra queries
+ * that getNextVideo() / getPreviousVideo() would each fire.
+ */
+ public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array
+ {
+ $idx = $orderedVideos->search(fn ($v) => $v->id === $current->id);
+ if ($idx === false) {
+ return [null, $orderedVideos->first()];
+ }
+ return [
+ $idx > 0 ? $orderedVideos[$idx - 1] : null,
+ $idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null,
+ ];
+ }
+
// Check if user owns this playlist
public function isOwnedBy($user)
{
diff --git a/app/Models/Video.php b/app/Models/Video.php
index 2c65f5f..a7d0117 100644
--- a/app/Models/Video.php
+++ b/app/Models/Video.php
@@ -75,6 +75,40 @@ class Video extends Model
return $this->slides()->count() > 1;
}
+ /**
+ * Resolve the slide list for a given audio track, applying the sharing rule:
+ * 1. Slides explicitly owned by this track (audio_track_id = $trackId).
+ * 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy).
+ * 3. Slides owned by any other track (first track in id order).
+ * 4. Empty (caller falls back to cover image).
+ *
+ * Pass `null` for the primary audio (the one stored on the videos row).
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function slidesForTrack(?int $audioTrackId)
+ {
+ $all = $this->slides; // eager-loaded collection of every slide
+
+ if ($audioTrackId !== null) {
+ $own = $all->where('audio_track_id', $audioTrackId)->values();
+ if ($own->isNotEmpty()) return $own;
+ }
+
+ // Primary / song-wide bucket (audio_track_id IS NULL).
+ $primary = $all->whereNull('audio_track_id')->values();
+ if ($primary->isNotEmpty()) return $primary;
+
+ // Borrow from the first track (by id) that has any.
+ $byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id');
+ if ($byTrack->isNotEmpty()) {
+ $firstTrackId = $byTrack->keys()->sort()->first();
+ return $byTrack[$firstTrackId]->values();
+ }
+
+ return collect();
+ }
+
// ── Local filesystem path helpers ─────────────────────────────────────────
/**
diff --git a/app/Models/VideoAudioTrack.php b/app/Models/VideoAudioTrack.php
index dc65629..d5e8540 100644
--- a/app/Models/VideoAudioTrack.php
+++ b/app/Models/VideoAudioTrack.php
@@ -14,6 +14,12 @@ class VideoAudioTrack extends Model
return $this->belongsTo(Video::class);
}
+ /** Slides explicitly owned by this track. Use Video::slidesForTrack() for fallback resolution. */
+ public function slides()
+ {
+ return $this->hasMany(VideoSlide::class, 'audio_track_id')->orderBy('position');
+ }
+
public function getStreamUrlAttribute(): string
{
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);
diff --git a/app/Models/VideoSlide.php b/app/Models/VideoSlide.php
index 267f137..4ed5754 100644
--- a/app/Models/VideoSlide.php
+++ b/app/Models/VideoSlide.php
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model
{
- protected $fillable = ['video_id', 'filename', 'position'];
+ protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
public function video()
{
return $this->belongsTo(Video::class);
}
+ public function audioTrack()
+ {
+ return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
+ }
+
public function getUrlAttribute(): string
{
return route('media.thumbnail', $this->filename);
diff --git a/app/Services/LlmLyricsService.php b/app/Services/LlmLyricsService.php
new file mode 100644
index 0000000..704a400
--- /dev/null
+++ b/app/Services/LlmLyricsService.php
@@ -0,0 +1,266 @@
+activeProvider() !== null;
+ }
+
+ public function cleanLyricsEnabled(): bool
+ {
+ return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true';
+ }
+
+ public function decorateEnabled(): bool
+ {
+ return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true';
+ }
+
+ /** The currently selected provider config, or null if none. */
+ public function activeProvider(): ?array
+ {
+ $providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
+ if (! $providers) return null;
+ $activeId = (string) Setting::get('llm_active_id', '');
+ foreach ($providers as $p) {
+ if (($p['id'] ?? null) === $activeId) {
+ $kind = $p['kind'] ?? 'ollama';
+ // An Ollama provider doesn't need a key; the others do.
+ if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null;
+ if (trim((string) ($p['model'] ?? '')) === '') return null;
+ return $p;
+ }
+ }
+ return null;
+ }
+
+ /** Returns clean lyric lines extracted by the LLM, or [] on any failure. */
+ public function cleanDescription(?string $description): array
+ {
+ if (! $description || ! $this->cleanLyricsEnabled()) return [];
+
+ $provider = $this->activeProvider();
+ // v2 cache key: the prompt was rewritten to stop dropping English/Thai
+ // lyric lines that happened to carry leading/trailing emoji decoration.
+ $cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description);
+ return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) {
+ $prompt = "Extract the SUNG lyric lines from this song description, preserving every\n"
+ . "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n"
+ . "and Thai, English and Italian, English and Arabic) — KEEP EVERY LANGUAGE.\n\n"
+ . "KEEP a line when it contains real lyric words, even if it's wrapped in or\n"
+ . " punctuated by emojis. Example: '🛡️💻 Met behind the firewalls 🌌' → KEEP.\n"
+ . " Strip ONLY the emojis themselves; the lyric words stay untouched.\n"
+ . "DROP a line ONLY when it is one of:\n"
+ . " • the song title or artist credit\n"
+ . " • a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n"
+ . " Pre-Chorus / Outro / Intro / 副歌 / 후렴 / كورس / ท่อน / etc.) — typically\n"
+ . " one or two words, possibly numbered\n"
+ . " • an instrument or production note inside 【…】 or 〔…〕 brackets\n"
+ . " • a row that is ONLY emojis / separators / decorative symbols with no words\n"
+ . " • commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n"
+ . "Hard rules:\n"
+ . " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n"
+ . " English to Thai). The output of each kept line must be in the SAME language\n"
+ . " and script as the original line.\n"
+ . " - DO NOT merge or split lines. One source lyric line → one output entry.\n"
+ . " - Preserve original punctuation (drop only the emojis).\n"
+ . " - Maintain the original order.\n\n"
+ . "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n"
+ . "DESCRIPTION:\n" . $description;
+
+ $raw = $this->call($prompt, 8192);
+ if ($raw === '') return [];
+
+ $raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
+ $arr = json_decode($raw, true);
+ if (! is_array($arr)) return [];
+ $out = [];
+ foreach ($arr as $line) {
+ $line = trim((string) $line);
+ if ($line === '') continue;
+ $out[] = $line;
+ }
+ return $out;
+ });
+ }
+
+ /**
+ * Rewrite each lyric line with heavy, expressive emoji styling. Emojis go
+ * inside the line AND at the end; multiple per line where it fits. The
+ * original words are NEVER changed — emojis are layered on top.
+ *
+ * Returns [index => decoratedLineText]. The caller swaps line.text and
+ * re-distributes the word timings across the new tokens.
+ */
+ public function decorateLines(array $lines): array
+ {
+ if (! $lines || ! $this->decorateEnabled()) return [];
+
+ $provider = $this->activeProvider();
+ $cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines));
+ return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) {
+ $numbered = [];
+ foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l;
+
+ $prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n"
+ . "Strict instructions:\n"
+ . "- Add emojis to almost every line (rich and visually striking, not minimal).\n"
+ . "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n"
+ . "- Use 2–4 emojis per line on average, more on emotional peaks.\n"
+ . "- Match emojis to the line's specific emotion, action, image, or vibe.\n"
+ . "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n"
+ . " • Aim for 30+ distinct emojis across the song.\n"
+ . " • Never reuse the same emoji on two adjacent lines.\n"
+ . " • Do NOT lean on the same 5–6 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
+ . " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
+ . " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
+ . "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
+ . " re-spelling, no script conversion. Preserve every original word verbatim.\n"
+ . "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
+ . " designed lyric post or viral TikTok caption.\n"
+ . "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
+ . " must map to exactly one output line, in the same order, with the same words.\n\n"
+ . "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
+ . "decorated line text. No prose, no markdown, no code fence.\n\n"
+ . "LINES:\n" . implode("\n", $numbered);
+
+ $raw = $this->call($prompt, 8192);
+ if ($raw === '') return [];
+ $raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
+ $obj = json_decode($raw, true);
+ if (! is_array($obj)) return [];
+
+ $out = [];
+ foreach ($obj as $k => $v) {
+ if (! is_string($v)) continue;
+ $v = trim($v);
+ if ($v === '') continue;
+ $idx = ((int) $k) - 1;
+ if ($idx < 0 || ! isset($lines[$idx])) continue;
+ // Cheap safety check: the original words must survive verbatim
+ // (the LLM should only LAYER emojis on top). Drop the
+ // decoration if too many original characters are missing.
+ if (! self::preservesOriginal($lines[$idx], $v)) continue;
+ $out[$idx] = $v;
+ }
+ return $out;
+ });
+ }
+
+ /**
+ * Verify the decorated line still contains every alphanumeric character of
+ * the original (in the same order). Stops the LLM from quietly rewording
+ * a line — we keep only decorations that strictly add emojis on top.
+ */
+ private static function preservesOriginal(string $orig, string $decorated): bool
+ {
+ $strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
+ $a = $strip($orig);
+ $b = $strip($decorated);
+ if ($a === '') return true;
+ // Sequential subsequence check: every char of $a must appear in $b in order.
+ $aLen = mb_strlen($a); $bLen = mb_strlen($b);
+ $j = 0;
+ for ($i = 0; $i < $aLen; $i++) {
+ $needle = mb_substr($a, $i, 1);
+ $found = false;
+ for (; $j < $bLen; $j++) {
+ if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
+ }
+ if (! $found) return false;
+ }
+ return true;
+ }
+
+
+ /** Dispatch to the active provider's adapter. */
+ private function call(string $prompt, int $maxTokens): string
+ {
+ $p = $this->activeProvider();
+ if (! $p) return '';
+ try {
+ return match ($p['kind']) {
+ 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
+ 'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
+ default => $this->callOllama($p, $prompt, $maxTokens),
+ };
+ } catch (\Throwable $e) {
+ Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
+ return '';
+ }
+ }
+
+ private function callOllama(array $p, string $prompt, int $maxTokens): string
+ {
+ $endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
+ $resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
+ 'model' => $p['model'],
+ 'messages' => [['role' => 'user', 'content' => $prompt]],
+ 'stream' => false,
+ 'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
+ ]);
+ if (! $resp->successful()) {
+ Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
+ return '';
+ }
+ $j = $resp->json();
+ return (string) ($j['message']['content'] ?? '');
+ }
+
+ private function callAnthropic(array $p, string $prompt, int $maxTokens): string
+ {
+ $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
+ $resp = Http::timeout(120)->withHeaders([
+ 'x-api-key' => (string) $p['api_key'],
+ 'anthropic-version' => '2023-06-01',
+ 'content-type' => 'application/json',
+ ])->post($endpoint . '/v1/messages', [
+ 'model' => $p['model'],
+ 'max_tokens' => $maxTokens,
+ 'messages' => [['role' => 'user', 'content' => $prompt]],
+ ]);
+ if (! $resp->successful()) {
+ Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
+ return '';
+ }
+ $j = $resp->json();
+ return (string) ($j['content'][0]['text'] ?? '');
+ }
+
+ private function callOpenAI(array $p, string $prompt, int $maxTokens): string
+ {
+ $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
+ $resp = Http::timeout(120)->withToken((string) $p['api_key'])
+ ->acceptJson()
+ ->post($endpoint . '/v1/chat/completions', [
+ 'model' => $p['model'],
+ 'messages' => [['role' => 'user', 'content' => $prompt]],
+ 'max_tokens' => $maxTokens,
+ 'temperature' => 0.2,
+ ]);
+ if (! $resp->successful()) {
+ Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
+ return '';
+ }
+ $j = $resp->json();
+ return (string) ($j['choices'][0]['message']['content'] ?? '');
+ }
+}
diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php
index c9d66d2..2463296 100644
--- a/app/Services/NasSyncService.php
+++ b/app/Services/NasSyncService.php
@@ -67,18 +67,69 @@ class NasSyncService
return $slug ?: 'video';
}
+ /**
+ * Top-level folder under users/{slug}/ for a video of the given type.
+ * Frozen at upload time — see CLAUDE.md (canonical storage layout).
+ */
+ public function typeFolder(Video $video): string
+ {
+ return match ($video->type) {
+ 'music' => 'music',
+ 'match' => 'sports',
+ default => 'videos', // generic + any unknown
+ };
+ }
+
+ /**
+ * Track folder name inside a music song folder. Format: {lang}-{db-id}.
+ * For the primary audio (no VideoAudioTrack row), pass null — the videos
+ * row id is used as the track id and the video's primary language as the
+ * language. Always lowercase. Falls back to 'xx' when no language is set.
+ */
+ public function trackFolderName(Video $video, ?VideoAudioTrack $track = null): string
+ {
+ $lang = mb_strtolower(trim((string) ($track ? $track->language : $video->language)));
+ if ($lang === '') $lang = 'xx';
+ $id = $track ? $track->id : $video->id;
+ return "{$lang}-{$id}";
+ }
+
+ /**
+ * Absolute NAS-relative path to a music track's folder. Music only.
+ * Caller must ensure $video->type === 'music'.
+ */
+ public function trackDir(Video $video, ?VideoAudioTrack $track = null): string
+ {
+ return $this->resolveVideoDir($video) . '/tracks/' . $this->trackFolderName($video, $track);
+ }
+
/**
* Return the NAS directory for a video.
*
* On first sync → pick a conflict-free slug and create the folder.
* On subsequent → find the existing folder by matching video ID in meta.json
* so renames of the title never lose the folder.
+ *
+ * Type-segregated: music → music/, sports → sports/, generic → videos/.
+ * The folder is frozen at first resolution — if the video's path already
+ * points somewhere, that location wins (so type edits don't move files).
*/
public function resolveVideoDir(Video $video): string
{
+ // Already organised — trust the stored path verbatim. This keeps legacy
+ // flat layouts working and prevents type edits from relocating files.
+ if (str_starts_with((string) $video->path, 'users/')) {
+ // Walk up past any tracks/{lang-id}/ then file-name segments.
+ $segs = explode('/', $video->path);
+ // Expect users/{slug}/{type-folder}/{video-slug}/...
+ if (count($segs) >= 4) {
+ return implode('/', array_slice($segs, 0, 4));
+ }
+ }
+
$video->loadMissing('user');
$userSlug = $this->userSlug($video->user);
- $base = "users/{$userSlug}/videos";
+ $base = "users/{$userSlug}/" . $this->typeFolder($video);
$titleSlug = $this->titleSlug($video->title);
// 1. Try the current title slug and numbered variants (-2, -3 …)
@@ -156,20 +207,22 @@ class NasSyncService
*/
public function localVideoDir(Video $video): string
{
- // Already organised — derive from path
- if (str_starts_with($video->path, 'users/')) {
- $dir = dirname(storage_path('app/' . $video->path));
- // If the primary file lives inside a 'tracks/' subfolder (promoted track),
- // go up one extra level to reach the video root directory.
- if (basename($dir) === 'tracks') {
- $dir = dirname($dir);
+ // Already organised — derive from path (respects whichever type folder it
+ // ended up in, even if the type has since been edited).
+ if (str_starts_with((string) $video->path, 'users/')) {
+ $segs = explode('/', $video->path);
+ if (count($segs) >= 4) {
+ return storage_path('app/' . implode('/', array_slice($segs, 0, 4)));
}
+ // Defensive fallback for malformed legacy paths
+ $dir = dirname(storage_path('app/' . $video->path));
+ if (basename($dir) === 'tracks') $dir = dirname($dir);
return $dir;
}
$video->loadMissing('user');
$userSlug = $this->userSlug($video->user);
- $base = storage_path("app/users/{$userSlug}/videos");
+ $base = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
$titleSlug = $this->titleSlug($video->title);
for ($i = 1; $i <= 50; $i++) {
@@ -206,60 +259,68 @@ class NasSyncService
$video->loadMissing(['user', 'slides']);
- $dir = $this->localVideoDir($video);
- $fileSlug = $this->titleSlug($video->title);
+ $videoDir = $this->localVideoDir($video); // users/{slug}/{type-folder}/{slug}
+ $isMusic = ($video->type === 'music');
+ // Music wraps the primary inside tracks/{lang-id}/; others stay flat.
+ $primaryDir = $isMusic
+ ? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
+ : $videoDir;
- @mkdir($dir, 0755, true);
+ @mkdir($primaryDir, 0755, true);
- $userSlug = $this->userSlug($video->user);
- $relDir = 'users/' . $userSlug . '/videos/' . basename($dir);
- $updates = [];
+ $userSlug = $this->userSlug($video->user);
+ $videoRel = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
+ $primaryRel = $isMusic
+ ? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
+ : $videoRel;
+ $updates = [];
- // ── Video file ───────────────────────────────────────────────────
+ // ── Video / primary audio file ───────────────────────────────────
$oldVideoPath = storage_path('app/' . $video->path);
if (file_exists($oldVideoPath)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
- $newFileName = "{$fileSlug}.{$ext}";
- rename($oldVideoPath, "{$dir}/{$newFileName}");
- $updates['path'] = "{$relDir}/{$newFileName}";
- $updates['filename'] = $newFileName;
+ $canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
+ rename($oldVideoPath, "{$primaryDir}/{$canonical}");
+ $updates['path'] = "{$primaryRel}/{$canonical}";
+ $updates['filename'] = $canonical;
}
- // ── Slides (process first; for audio, thumbnail IS slide 0) ──────
+ // ── Slides — music primary track only ────────────────────────────
$firstSlideRelPath = null;
- if ($video->slides->isNotEmpty()) {
- @mkdir("{$dir}/slides", 0755, true);
+ if ($isMusic && $video->slides->isNotEmpty()) {
+ @mkdir("{$primaryDir}/slides", 0755, true);
foreach ($video->slides->sortBy('position') as $slide) {
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
if (! file_exists($oldSlidePath)) continue;
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
- $newSlideName = "{$slide->id}.{$ext}";
- rename($oldSlidePath, "{$dir}/slides/{$newSlideName}");
- $newSlideFilename = "{$relDir}/slides/{$newSlideName}";
+ $newSlideName = "{$slide->position}.{$ext}";
+ rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
+ $newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
$slide->update(['filename' => $newSlideFilename]);
if ($firstSlideRelPath === null) {
$firstSlideRelPath = $newSlideFilename;
}
}
- // For audio uploads the thumbnail is the first slide
if ($firstSlideRelPath !== null) {
$updates['thumbnail'] = $firstSlideRelPath;
}
}
- // ── Standalone thumbnail (video uploads, no slides) ──────────────
+ // ── Standalone thumbnail (sports/generic + music-without-slides) ─
if ($video->thumbnail && ! isset($updates['thumbnail'])) {
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($oldThumbPath)) {
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
$newThumbName = "thumb.{$ext}";
- rename($oldThumbPath, "{$dir}/{$newThumbName}");
- $updates['thumbnail'] = "{$relDir}/{$newThumbName}";
+ $thumbDirAbs = $isMusic ? $primaryDir : $videoDir;
+ $thumbRelDir = $isMusic ? $primaryRel : $videoRel;
+ rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
+ $updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
}
}
- // ── meta.json (enables localVideoDir to identify this dir later) ─
- $this->writeLocalMeta($video, $dir);
+ // ── meta.json (lives at the video / song root, not per-track) ────
+ $this->writeLocalMeta($video, $videoDir);
if (! empty($updates)) {
$video->update($updates);
@@ -603,41 +664,46 @@ class NasSyncService
): void {
$video->loadMissing(['user', 'slides']);
- $dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video
- $fileSlug = $this->titleSlug($video->title);
-
- $this->mkdirp($dir);
+ $videoDir = $this->resolveVideoDir($video); // users/{slug}/{type-folder}/{slug}
+ $isMusic = ($video->type === 'music');
+ // Music uses per-track folders. Primary audio + its slides + lyrics live in
+ // tracks/{lang-id}/. Sports + generic stay flat: video.{ext} at the root.
+ $primaryDir = $isMusic
+ ? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
+ : $videoDir;
+ $this->mkdirp($primaryDir);
$updates = [];
- // ── Video file ───────────────────────────────────────────────────
+ // ── Video / primary audio file ───────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) {
- $ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
+ // New canonical name. Music: audio.{ext}. Sports/generic: video.{ext}.
+ $canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
+ $nasFile = "{$primaryDir}/{$canonical}";
+ $ok = $this->putFile($tempVideoPath, $nasFile);
if (! $ok) {
- // Leave the local temp file intact so the caller can fall back to local storage
- throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$fileSlug}.{$ext}");
+ throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
}
@unlink($tempVideoPath);
- $updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
- $updates['filename'] = "{$fileSlug}.{$ext}";
+ $updates['path'] = $nasFile;
+ $updates['filename'] = $canonical;
}
- // ── Slides (audio uploads — thumbnail IS the first slide) ────────
+ // ── Slides — music primary track only ────────────────────────────
$firstSlideNasPath = null;
- if (! empty($slideAbsPaths)) {
- $this->mkdirp("{$dir}/slides");
+ if ($isMusic && ! empty($slideAbsPaths)) {
+ $this->mkdirp("{$primaryDir}/slides");
foreach ($video->slides->sortBy('position') as $slide) {
$absPath = $slideAbsPaths[$slide->position] ?? null;
if (! $absPath || ! file_exists($absPath)) continue;
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
- $nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}";
+ $nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
if ($this->putFile($absPath, $nasSlideFile)) {
@unlink($absPath);
- $slideRelPath = $nasSlideFile;
- $slide->update(['filename' => $slideRelPath]);
+ $slide->update(['filename' => $nasSlideFile]);
if ($firstSlideNasPath === null) {
- $firstSlideNasPath = $slideRelPath;
+ $firstSlideNasPath = $nasSlideFile;
}
}
}
@@ -646,26 +712,31 @@ class NasSyncService
}
}
- // ── Standalone thumbnail (video uploads without slides) ──────────
+ // ── Standalone thumbnail (sports/generic + music-without-slides) ──
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
- if ($this->putFile($tempThumbPath, "{$dir}/thumb.webp")) {
+ // Music thumb lives next to the primary audio inside the track folder.
+ $thumbDir = $isMusic ? $primaryDir : $videoDir;
+ $thumbExt = pathinfo($tempThumbPath, PATHINFO_EXTENSION) ?: 'webp';
+ $nasThumb = "{$thumbDir}/thumb.{$thumbExt}";
+ if ($this->putFile($tempThumbPath, $nasThumb)) {
@unlink($tempThumbPath);
- $updates['thumbnail'] = "{$dir}/thumb.webp";
+ $updates['thumbnail'] = $nasThumb;
}
}
- // ── meta.json ────────────────────────────────────────────────────
+ // ── meta.json (song / video root level, not per-track) ───────────
$this->putContent(json_encode([
'id' => $video->id,
'user_id' => $video->user_id,
'title' => $video->title,
+ 'type' => $video->type,
'created_at' => $video->created_at?->toIso8601String(),
- ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$videoDir}/meta.json");
// File is now on NAS and accessible — mark as ready
$updates['status'] = 'ready';
- Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $dir]);
+ Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);
$video->update($updates);
}
@@ -705,6 +776,17 @@ class NasSyncService
}
}
+ // lyrics/*.json (source-of-truth; push any local mirror that exists)
+ $video->loadMissing('audioTracks');
+ $lyricsTargets = array_merge([null], $video->audioTracks->all());
+ foreach ($lyricsTargets as $lt) {
+ $localLyrics = $this->lyricsLocalPath($video, $lt);
+ if (is_file($localLyrics)) {
+ $this->mkdirp("{$dir}/lyrics");
+ $this->putFile($localLyrics, $this->lyricsNasPath($video, $lt));
+ }
+ }
+
// meta.json (always written last so readMeta can find the folder)
$this->putContent(json_encode([
'id' => $video->id,
@@ -994,6 +1076,130 @@ class NasSyncService
return $content !== false ? $content : null;
}
+ // ── Lyrics (per-track, source-of-truth JSON synced to NAS) ─────────────────
+
+ public function lyricsDir(Video $video): string
+ {
+ // Legacy shared lyrics/ folder location — kept for read-fallback only.
+ // Derived from the stored path (no NAS lookup) — see callers for context.
+ if (str_starts_with((string) $video->path, 'users/')) {
+ $dir = dirname($video->path);
+ if (basename($dir) === 'tracks') $dir = dirname($dir);
+ return $dir . '/lyrics';
+ }
+ return $this->resolveVideoDir($video) . '/lyrics';
+ }
+
+ /**
+ * NAS-relative path for a track's lyrics under the new per-track-folder layout:
+ * tracks/{lang-id}/lyrics.json
+ * Falls back to the legacy shared lyrics/ folder if the song still uses the
+ * old flat layout (we detect this by checking whether the path passes through
+ * a tracks/ segment).
+ */
+ public function lyricsNasPath(Video $video, ?VideoAudioTrack $track = null): string
+ {
+ // New layout (music with per-track folders).
+ if ($video->type === 'music' && str_starts_with((string) $video->path, 'users/')) {
+ $segs = explode('/', $video->path);
+ // users/{slug}/music/{song-slug}/tracks/{lang-id}/audio.{ext}
+ if (count($segs) >= 6 && $segs[4] === 'tracks') {
+ $songRoot = implode('/', array_slice($segs, 0, 4));
+ $folder = $this->trackFolderName($video, $track);
+ return "{$songRoot}/tracks/{$folder}/lyrics.json";
+ }
+ }
+ // Legacy fallback — shared lyrics/ folder, keyed by track suffix.
+ $name = $track ? "track-{$track->id}" : 'primary';
+ return $this->lyricsDir($video) . "/{$name}.json";
+ }
+
+ /** Canonical local mirror path for a track's lyrics. */
+ public function lyricsLocalPath(Video $video, ?VideoAudioTrack $track = null): string
+ {
+ return storage_path('app/' . $this->lyricsNasPath($video, $track));
+ }
+
+ /**
+ * Write lyrics JSON for a track. Always writes the local mirror; pushes to
+ * NAS when reachable. Returns false only if the NAS push was attempted and
+ * failed (the local copy is written regardless, so nas:auto-sync can retry).
+ */
+ public function putLyrics(Video $video, ?VideoAudioTrack $track, array $data): bool
+ {
+ $json = json_encode($data, JSON_UNESCAPED_UNICODE);
+ $local = $this->lyricsLocalPath($video, $track);
+ if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
+ file_put_contents($local, $json);
+
+ if ($this->isEnabled()) {
+ // Make sure the directory containing this lyrics file exists on NAS.
+ // New layout: tracks/{lang-id}/. Legacy: shared lyrics/ folder.
+ $this->mkdirp(dirname($this->lyricsNasPath($video, $track)));
+ return $this->putContent($json, $this->lyricsNasPath($video, $track));
+ }
+ return true;
+ }
+
+ /**
+ * Read lyrics JSON from the LOCAL mirror only — never touches the NAS.
+ * Use this on hot paths (page render, player-data) so a missing file can't
+ * block the request on smbclient or the port-445 reachability probe. The
+ * lyrics mirror is written by putLyrics() and is never wiped by cache
+ * cleanup, so a song that has lyrics will have them locally.
+ */
+ public function getLocalLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
+ {
+ $local = $this->lyricsLocalPath($video, $track);
+ if (! is_file($local)) {
+ // Read fallback to the legacy shared lyrics/ folder so songs that
+ // haven't been migrated yet still serve their lyrics.
+ $legacy = storage_path('app/' . $this->lyricsDir($video) . '/' . ($track ? "track-{$track->id}" : 'primary') . '.json');
+ if (is_file($legacy)) $local = $legacy;
+ }
+ if (is_file($local)) {
+ $d = json_decode((string) file_get_contents($local), true);
+ if (is_array($d)) return $d;
+ }
+ return null;
+ }
+
+ /**
+ * Remove a track's lyrics from both the local mirror and the NAS. Used by
+ * the owner when the generated lyrics are wrong and they want to start
+ * over — after calling this, the next Generate produces a fresh file.
+ */
+ public function deleteLyrics(Video $video, ?VideoAudioTrack $track = null): void
+ {
+ $local = $this->lyricsLocalPath($video, $track);
+ if (is_file($local)) @unlink($local);
+
+ if ($this->isEnabled()) {
+ try { $this->deleteFile($this->lyricsNasPath($video, $track)); }
+ catch (\Throwable $e) { /* best-effort: local removal is what matters for next regenerate */ }
+ }
+ }
+
+ /** Read lyrics JSON for a track, pulling from NAS into the local mirror if needed. */
+ public function getLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
+ {
+ $local = $this->lyricsLocalPath($video, $track);
+ if (is_file($local)) {
+ $d = json_decode((string) file_get_contents($local), true);
+ if (is_array($d)) return $d;
+ }
+ if ($this->isEnabled()) {
+ $c = $this->getContent($this->lyricsNasPath($video, $track));
+ if ($c !== null) {
+ if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
+ file_put_contents($local, $c);
+ $d = json_decode($c, true);
+ if (is_array($d)) return $d;
+ }
+ }
+ return null;
+ }
+
public function deleteFile(string $nasRelPath): void
{
$cfg = $this->cfg();
diff --git a/app/Support/LyricsAss.php b/app/Support/LyricsAss.php
new file mode 100644
index 0000000..ec7c7f8
--- /dev/null
+++ b/app/Support/LyricsAss.php
@@ -0,0 +1,109 @@
+ 0) $out .= '{\k' . $lead . '}';
+
+ $n = count($words);
+ foreach ($words as $i => $w) {
+ $wStart = (float) $w['start'];
+ $wEnd = (float) $w['end'];
+ $dur = max(1, (int) round(($wEnd - $wStart) * 100));
+ $out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
+
+ if ($i < $n - 1) {
+ $gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
+ $out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
+ }
+ }
+ return $out;
+ }
+
+ /** ASS timestamp: H:MM:SS.cc (centiseconds). */
+ private static function ts(float $t): string
+ {
+ if ($t < 0) $t = 0;
+ $cs = (int) round($t * 100);
+ $h = intdiv($cs, 360000);
+ $m = intdiv($cs % 360000, 6000);
+ $s = intdiv($cs % 6000, 100);
+ $c = $cs % 100;
+ return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
+ }
+
+ private static function escape(string $s): string
+ {
+ // Strip ASS override delimiters and collapse newlines.
+ $s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
+ return str_replace(["\r\n", "\n", "\r"], ' ', $s);
+ }
+
+ private static function header(): string
+ {
+ // Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
+ // (translucent grey), heavy outline + shadow for legibility over artwork.
+ return "[Script Info]\n"
+ . "ScriptType: v4.00+\n"
+ . 'PlayResX: ' . self::W . "\n"
+ . 'PlayResY: ' . self::H . "\n"
+ . "WrapStyle: 0\n"
+ . "ScaledBorderAndShadow: yes\n\n"
+ . "[V4+ Styles]\n"
+ . "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
+ . "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
+ . "[Events]\n"
+ . "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
+ }
+}
diff --git a/app/Support/LyricsDescriptionParser.php b/app/Support/LyricsDescriptionParser.php
new file mode 100644
index 0000000..b264681
--- /dev/null
+++ b/app/Support/LyricsDescriptionParser.php
@@ -0,0 +1,144 @@
+ for line breaks — convert those (and other
+ // block-ending tags) into real newlines BEFORE stripping tags, otherwise
+ // the entire body collapses into one long run-on line.
+ // Heading tags (…) carry the song title — drop their content
+ // entirely so the title never leaks into the lyric list.
+ $text = preg_replace('/<\s*h[1-6][^>]*>.*?<\s*\/\s*h[1-6]\s*>/isu', "\n", $desc);
+ $text = preg_replace('/<\s*br\s*\/?>/i', "\n", $text);
+ $text = preg_replace('/<\s*\/\s*(p|div|li|tr|blockquote)\s*>/i', "\n", $text);
+ $text = strip_tags($text);
+ // Decode HTML entities ( , &, etc.) so the comparison later isn't fooled.
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = preg_replace('/\r\n|\r/u', "\n", $text);
+
+ // First pass: clean each line and flag section headers (Verse / Ritornello
+ // / Bridge / etc.) so they can be dropped — those aren't sung.
+ $cleaned = [];
+ foreach (explode("\n", $text) as $line) {
+ $line = self::cleanLine($line);
+ if ($line === null) continue;
+ $cleaned[] = ['text' => $line, 'header' => self::isSectionHeader($line)];
+ }
+
+ // Title detection: if the first non-header line is immediately followed by
+ // a section header (e.g. "Figlio Mio — Viaggio di Vita" then "Verso 1"),
+ // that first line is the song title — drop it too.
+ $firstIdx = null;
+ foreach ($cleaned as $i => $c) {
+ if (! $c['header']) { $firstIdx = $i; break; }
+ }
+ $dropTitle = false;
+ if ($firstIdx !== null) {
+ for ($j = $firstIdx + 1; $j < count($cleaned); $j++) {
+ if ($cleaned[$j]['header']) { $dropTitle = true; break; }
+ break; // first thing after is a real lyric line → not a title block
+ }
+ }
+
+ $out = [];
+ foreach ($cleaned as $i => $c) {
+ if ($c['header']) continue;
+ if ($dropTitle && $i === $firstIdx) continue;
+ $out[] = $c['text'];
+ }
+
+ // Avoid mistakenly aligning non-lyric descriptions (a credit line, a URL,
+ // etc.). Require at least a handful of plausible lyric lines.
+ if (count($out) < self::MIN_LYRIC_LINES) return [];
+
+ return $out;
+ }
+
+ /**
+ * True when a line is a section marker (Verse / Chorus / Bridge / Outro /
+ * their many translations) rather than a sung lyric. Matches the WHOLE line
+ * so a real lyric containing one of these words isn't mistakenly dropped.
+ */
+ private static function isSectionHeader(string $line): bool
+ {
+ $t = mb_strtolower(trim($line));
+ if ($t === '') return false;
+
+ $roots = [
+ 'intro', 'outro', 'interlude', 'instrumental',
+ 'verse', 'verso', 'verset', 'couplet', 'estrofa', 'strofa',
+ 'chorus', 'ritornello', 'refrain', 'refrão', 'refrao', 'coro', 'estribillo',
+ 'pre[\s\-]?chorus', 'pre[\s\-]?ritornello', 'pre[\s\-]?coro',
+ 'pre[\s\-]?refrain', 'pre[\s\-]?refrão', 'pré[\s\-]?refrain',
+ 'bridge', 'ponte', 'puente', 'pont', 'brücke', 'brucke',
+ 'hook', 'drop', 'breakdown', 'tag', 'vamp', 'coda', 'reprise',
+ // CJK / Thai / Arabic / Korean
+ 'サビ', 'コーラス', 'バース', 'ブリッジ', 'イントロ', 'アウトロ', 'フック', '間奏',
+ 'ท่อน', 'คอรัส', 'ฮุก', 'บริดจ์', 'อินโทร', 'เอาท์โทร',
+ '前奏', '副歌', '桥段', '主歌', '尾奏',
+ 'كورس', 'بريدج', 'كوبليه',
+ '후렴', '브릿지', '인트로', '아웃트로', '훅',
+ ];
+ $rootRe = implode('|', $roots);
+ // Optional trailing number, "final/finale/reprise", roman numerals.
+ $pattern = '/^(?:' . $rootRe . ')[\s\d:\-—\.]*(?:final|finale|reprise|ii|iii|iv|v|vi|2|3|4|5)?\s*$/iu';
+ return (bool) preg_match($pattern, $t);
+ }
+
+ /** Returns the cleaned line, or null if it should be discarded. */
+ private static function cleanLine(string $line): ?string
+ {
+ $line = trim($line);
+ if ($line === '') return null;
+
+ // Strip markdown emphasis (* _ ~) and leading list bullets / quote markers.
+ $line = preg_replace('/^[\s>\-\*•♪♫·]+/u', '', $line);
+ $line = preg_replace('/[\*_~`]+/u', '', $line);
+
+ // Drop instrument / section annotations inside Japanese-style brackets:
+ // 【 箏・尺八・篠笛・優しい歌声 】 — these aren't lyrics.
+ $line = preg_replace('/【[^】]*】/u', '', $line);
+ $line = preg_replace('/〔[^〕]*〕/u', '', $line);
+ $line = preg_replace('/\[\[[^\]]*\]\]/u', '', $line);
+
+ // Strip emoji / pictographic symbols and the invisible glue that often
+ // sticks to them (variation selectors, ZWJ) so nothing leaves behind a
+ // bare diacritic when the visible emoji is removed.
+ $line = preg_replace(
+ '/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{0F3A}-\x{0F3D}\x{FE00}-\x{FE0F}\x{200B}-\x{200F}\x{2060}]/u',
+ '', $line
+ );
+
+ // Collapse internal whitespace.
+ $line = preg_replace('/\s+/u', ' ', $line);
+ $line = trim($line);
+
+ if ($line === '') return null;
+
+ // Must contain at least one letter (Unicode), and at least 3 characters
+ // after stripping — discards "🌸 平穏 🌸" (header) and "──" separators.
+ if (! preg_match('/\p{L}/u', $line)) return null;
+ if (mb_strlen($line) < 3) return null;
+
+ return $line;
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 037e17d..71caf8d 100755
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -15,6 +15,14 @@ $app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);
+// Framework storage directory lives at base_path('data') instead of the default
+// base_path('storage'). This keeps the project tree free of two "storage"
+// entries — the only `storage` visible is the public-facing symlink at
+// public/storage (→ data/app/public). All Laravel storage_path() calls,
+// session/cache/log/view writes, and the local NAS file cache resolve through
+// here transparently.
+$app->useStoragePath(base_path('data'));
+
/*
|--------------------------------------------------------------------------
| Bind Important Interfaces
diff --git a/database/migrations/2026_05_31_000001_add_audio_track_id_to_video_slides.php b/database/migrations/2026_05_31_000001_add_audio_track_id_to_video_slides.php
new file mode 100644
index 0000000..4edfe00
--- /dev/null
+++ b/database/migrations/2026_05_31_000001_add_audio_track_id_to_video_slides.php
@@ -0,0 +1,31 @@
+foreignId('audio_track_id')
+ ->nullable()
+ ->after('video_id')
+ ->constrained('video_audio_tracks')
+ ->nullOnDelete();
+
+ $table->index(['video_id', 'audio_track_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('video_slides', function (Blueprint $table) {
+ $table->dropIndex(['video_id', 'audio_track_id']);
+ $table->dropConstrainedForeignId('audio_track_id');
+ });
+ }
+};
diff --git a/database/migrations/2026_05_31_000002_add_playlist_views_tracking.php b/database/migrations/2026_05_31_000002_add_playlist_views_tracking.php
new file mode 100644
index 0000000..e522919
--- /dev/null
+++ b/database/migrations/2026_05_31_000002_add_playlist_views_tracking.php
@@ -0,0 +1,49 @@
+unsignedBigInteger('view_count')->default(0)->after('share_token');
+ });
+
+ Schema::create('playlist_views', function (Blueprint $table) {
+ $table->id();
+ $table->unsignedBigInteger('playlist_id');
+ $table->unsignedBigInteger('user_id')->nullable();
+ $table->string('device_id', 64)->nullable();
+ $table->string('device_hash', 64)->nullable();
+ $table->string('ip_address', 64)->nullable();
+ $table->string('country', 8)->nullable();
+ $table->string('country_name', 64)->nullable();
+ $table->string('user_agent', 512)->nullable();
+ $table->timestamp('viewed_at');
+
+ $table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
+ $table->index(['playlist_id', 'viewed_at']);
+ $table->index(['playlist_id', 'user_id', 'viewed_at']);
+ $table->index(['playlist_id', 'device_id', 'viewed_at']);
+ $table->index(['playlist_id', 'device_hash', 'viewed_at']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('playlist_views');
+ Schema::table('playlists', function (Blueprint $table) {
+ $table->dropColumn('view_count');
+ });
+ }
+};
diff --git a/ml/transcribe.py b/ml/transcribe.py
new file mode 100644
index 0000000..d4bc1b3
--- /dev/null
+++ b/ml/transcribe.py
@@ -0,0 +1,895 @@
+#!/usr/bin/env python3
+"""
+Lyrics transcription + word-level alignment pipeline.
+
+Pipeline: Demucs (isolate vocals) -> WhisperX transcribe (large-v3) -> forced
+word alignment. Emits a JSON file with line- and word-level timestamps that the
+web player overlay and the ASS subtitle burner both consume.
+
+Usage:
+ transcribe.py --audio /abs/song.mp3 --out /abs/lyrics.json \
+ [--language en] [--gpu 0] [--model large-v3] [--no-demucs]
+
+All heavy logs go to stderr; stdout stays clean. Exit code 0 on success.
+The output JSON shape is:
+ {
+ "version": 1,
+ "language": "en",
+ "source": "whisperx",
+ "model": "large-v3",
+ "demucs": true,
+ "lines": [
+ {"start": 12.30, "end": 16.80, "text": "...",
+ "words": [{"start": 12.30, "end": 12.55, "text": "..."}]}
+ ]
+ }
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+def log(*a):
+ print(*a, file=sys.stderr, flush=True)
+
+
+# Progress file path, set from --progress. The web layer polls a status endpoint
+# that reads this file to drive a live progress bar.
+_PROGRESS_PATH = None
+
+
+def write_progress(pct: int, stage: str):
+ if not _PROGRESS_PATH:
+ return
+ try:
+ tmp = _PROGRESS_PATH + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump({"status": "processing", "pct": int(pct), "stage": stage}, f)
+ os.replace(tmp, _PROGRESS_PATH)
+ except Exception:
+ pass # progress is best-effort, never fail the run over it
+
+
+def isolate_vocals(audio_path: str, gpu: int | None) -> str | None:
+ """Run Demucs two-stem separation and return the path to vocals.wav.
+
+ Returns None if separation fails so the caller can fall back to the raw mix.
+ """
+ tmp_dir = tempfile.mkdtemp(prefix="demucs_")
+ cmd = [
+ sys.executable, "-m", "demucs",
+ "--two-stems", "vocals",
+ "-n", "htdemucs",
+ "-o", tmp_dir,
+ audio_path,
+ ]
+ env = dict(os.environ)
+ if gpu is not None:
+ env["CUDA_VISIBLE_DEVICES"] = str(gpu)
+ cmd += ["-d", "cuda"]
+ else:
+ cmd += ["-d", "cpu"]
+
+ log(f"[demucs] separating vocals -> {tmp_dir}")
+ try:
+ # Stream stderr so demucs' tqdm percentage drives live progress (8→38%).
+ import re
+ proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, bufsize=0)
+ buf = b""
+ last = -1
+ while True:
+ chunk = proc.stderr.read(64)
+ if not chunk:
+ break
+ buf += chunk
+ # tqdm overwrites with \r; scan the tail for the newest "NN%".
+ text = buf[-200:].decode("utf-8", "ignore")
+ m = re.findall(r"(\d{1,3})%", text)
+ if m:
+ p = int(m[-1])
+ if 0 <= p <= 100 and p != last:
+ last = p
+ write_progress(8 + int(p * 0.30), "Separating vocals")
+ proc.wait()
+ if proc.returncode != 0:
+ log(f"[demucs] exited {proc.returncode}; falling back to raw mix")
+ return None
+ except Exception as e:
+ log(f"[demucs] failed ({e}); falling back to raw mix")
+ return None
+
+ stem = Path(audio_path).stem
+ vocals = Path(tmp_dir) / "htdemucs" / stem / "vocals.wav"
+ if vocals.exists():
+ log(f"[demucs] vocals at {vocals}")
+ return str(vocals)
+ log("[demucs] vocals.wav not found; falling back to raw mix")
+ return None
+
+
+# Karaoke display lines are short — we re-split a segment's words on natural
+# pauses, a soft word cap, and (for spaced scripts) clause punctuation / new
+# capitalised lines.
+LINE_GAP = 0.65 # seconds of silence that ends a display line
+LINE_MAX_WORDS = 12 # hard cap so Latin-script lines never overflow
+LINE_MAX_CHARS = 30 # char cap for spaceless scripts (Thai/CJK/…)
+LINE_MIN_WORDS = 3 # don't break on punctuation before this many words
+PUNCT_END = (".", ",", "!", "?", ";", ":", "—")
+# Scripts written without spaces between words — join tokens directly and split
+# by character count instead of word count.
+SPACELESS = {"th", "zh", "ja", "lo", "my", "km", "yue", "wuu"}
+# Languages that use a non-Latin script — used to detect a mis-forced pass (a
+# Thai/Arabic/… pass that produced Latin text is really a misheard English part).
+NONLATIN_LANGS = {
+ "th", "zh", "ja", "ko", "ar", "he", "ru", "uk", "bg", "sr", "mk", "el",
+ "hi", "bn", "ta", "te", "kn", "ml", "mr", "ne", "si", "my", "km", "lo",
+ "ka", "am", "fa", "ur", "ps", "yue", "wuu", "yi",
+}
+
+
+def _emit(words: list, lang: str) -> dict | None:
+ if not words:
+ return None
+ sep = "" if lang in SPACELESS else " "
+ return {
+ "start": words[0]["start"],
+ "end": words[-1]["end"],
+ "text": sep.join(w["text"] for w in words),
+ "lang": lang,
+ "words": words,
+ }
+
+
+def _norm_for_match(s: str) -> str:
+ """Normalize text for similarity comparison (lowercase, keep letters/numbers
+ including non-ASCII scripts; drop everything else)."""
+ out = []
+ for c in s or "":
+ if c.isalnum():
+ out.append(c.lower())
+ return "".join(out)
+
+
+def _guess_lang_from_script(text: str) -> str:
+ """Best-effort language guess from a line's Unicode script (used when we have
+ no whisper anchor to inherit the language from)."""
+ for c in text or "":
+ co = ord(c)
+ if 0x3040 <= co <= 0x30FF or 0x4E00 <= co <= 0x9FFF:
+ return "ja"
+ if 0x0E00 <= co <= 0x0E7F:
+ return "th"
+ if 0xAC00 <= co <= 0xD7AF:
+ return "ko"
+ if 0x0600 <= co <= 0x06FF:
+ return "ar"
+ if 0x0400 <= co <= 0x04FF:
+ return "ru"
+ return "en"
+
+
+def _redistribute_words(start: float, end: float, text: str, lang: str) -> list:
+ """Evenly distribute the line's [start,end] across its tokens — words for
+ spaced languages, characters for spaceless scripts (Thai/CJK/…)."""
+ if not text or end <= start:
+ return []
+ tokens = list(text) if lang in SPACELESS else text.split()
+ tokens = [t for t in tokens if t.strip()]
+ n = len(tokens)
+ if n == 0:
+ return []
+ slot = (end - start) / n
+ return [{"start": round(start + i * slot, 3),
+ "end": round(start + (i + 1) * slot, 3),
+ "text": t} for i, t in enumerate(tokens)]
+
+
+def _distribute_in_vocal_regions(lines: list, regions: list,
+ gap_start: float, gap_end: float) -> list:
+ """Place each line at a moment within [gap_start, gap_end] where vocals
+ are actually active. `regions` is a list of (start, end) seconds covering
+ the whole song. Falls back to even spread if no vocal activity is detected
+ in the gap (e.g. instrumental break with no vocals at all)."""
+ gap_regions = []
+ for s, e in regions:
+ s_clip = max(s, gap_start)
+ e_clip = min(e, gap_end)
+ if e_clip - s_clip >= 0.3:
+ gap_regions.append((s_clip, e_clip))
+
+ N = len(lines)
+ if N == 0: return []
+ if not gap_regions or gap_end <= gap_start:
+ # No vocals in the gap — last-resort even spread so coverage isn't lost.
+ if gap_end <= gap_start: return []
+ slot = (gap_end - gap_start) / N
+ out = []
+ for k, ul in enumerate(lines):
+ s = gap_start + k * slot
+ e = gap_start + (k + 1) * slot
+ lang = _guess_lang_from_script(ul)
+ out.append({"start": round(s, 3), "end": round(e, 3),
+ "text": ul, "lang": lang,
+ "words": _redistribute_words(s, e, ul, lang)})
+ return out
+
+ M = len(gap_regions)
+ out = []
+
+ if N <= M:
+ # Fewer lines than vocal regions — pick N regions roughly evenly spaced
+ # and start each line at its region's start. Each line ends at the next
+ # selected region's start (or its own region's end if last).
+ chosen = [int(round(i * (M - 1) / max(1, N - 1))) if N > 1 else 0 for i in range(N)]
+ # Ensure strictly increasing
+ for i in range(1, len(chosen)):
+ if chosen[i] <= chosen[i - 1]:
+ chosen[i] = min(M - 1, chosen[i - 1] + 1)
+ for i, ul in enumerate(lines):
+ rs, re = gap_regions[chosen[i]]
+ if i + 1 < N:
+ nxt_rs = gap_regions[chosen[i + 1]][0]
+ line_end = min(re, nxt_rs - 0.05)
+ else:
+ line_end = re
+ line_end = max(rs + 0.4, line_end)
+ lang = _guess_lang_from_script(ul)
+ out.append({"start": round(rs, 3), "end": round(line_end, 3),
+ "text": ul, "lang": lang,
+ "words": _redistribute_words(rs, line_end, ul, lang)})
+ else:
+ # More lines than vocal regions — assign multiple lines per region,
+ # divided proportionally to each region's duration so longer regions
+ # take more lines.
+ total = sum(e - s for s, e in gap_regions)
+ line_idx = 0
+ consumed = 0.0
+ for ri, (rs, re) in enumerate(gap_regions):
+ # Lines that should land in this region: proportional to its share
+ # of total vocal time, rounded so the last region takes the rest.
+ if ri == M - 1:
+ n_here = N - line_idx
+ else:
+ consumed += re - rs
+ target = int(round(consumed / total * N))
+ n_here = max(0, target - line_idx)
+ if n_here <= 0: continue
+ slot = (re - rs) / n_here
+ for k in range(n_here):
+ if line_idx >= N: break
+ s = rs + k * slot
+ e = rs + (k + 1) * slot
+ ul = lines[line_idx]
+ lang = _guess_lang_from_script(ul)
+ out.append({"start": round(s, 3), "end": round(e, 3),
+ "text": ul, "lang": lang,
+ "words": _redistribute_words(s, e, ul, lang)})
+ line_idx += 1
+ return out
+
+
+def correct_whisper_with_description(whisper_lines: list, user_lines: list,
+ audio_duration: float = 0.0,
+ vocal_regions: list = None) -> list:
+ """Description-first alignment, with whisper used only as structural anchors:
+ 1. Find HIGH-confidence whisper-to-description matches (sim ≥ STRONG).
+ Weak/spurious matches are ignored — they cause downstream skips and
+ misplacements (e.g. line #5 anchored at 30s because of a loose match,
+ making line #4 disappear).
+ 2. The strong anchors partition the description into segments. Each
+ segment of description lines is distributed across the vocal regions
+ in its time window — so every line lands on actual singing and every
+ line appears exactly once, in order.
+ 3. No description line is ever skipped; no weak match consumes the wrong
+ slot; every output line carries description text (never whisper).
+
+ Falls back to pure vocal-region distribution if no strong anchors exist.
+ """
+ from difflib import SequenceMatcher
+ if not user_lines:
+ return whisper_lines or []
+
+ U = [u for u in user_lines if u.strip()]
+ if not U:
+ return whisper_lines or []
+
+ vocal_regions = vocal_regions or []
+ audio_end = max(audio_duration, 10.0)
+ if vocal_regions:
+ audio_end = max(audio_end, vocal_regions[-1][1])
+
+ # ── Find strong anchors ────────────────────────────────────────────────
+ # Only matches at STRONG similarity (0.55+) count as anchors. Anything
+ # less confident than that has historically misled the alignment.
+ user_script = [_guess_lang_from_script(u) for u in U]
+ user_norm = [_norm_for_match(u) for u in U]
+
+ LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
+ def same_script(a: str, b: str) -> bool:
+ if a in LATIN and b in LATIN: return True
+ return a == b
+
+ STRONG = 0.55
+ SKIP_AHEAD = 10
+
+ anchors = [] # list of (user_idx, whisper_start, whisper_end)
+ next_u = 0
+ for w in (whisper_lines or []):
+ w_text = (w.get("text") or "").strip()
+ if not w_text: continue
+ w_lang = w.get("lang") or _guess_lang_from_script(w_text)
+ w_norm = _norm_for_match(w_text)
+ if not w_norm: continue
+ best_u = -1; best_sim = 0.0
+ end = min(next_u + SKIP_AHEAD + 1, len(U))
+ for ui in range(next_u, end):
+ if not same_script(user_script[ui], w_lang): continue
+ if not user_norm[ui]: continue
+ sim = SequenceMatcher(None, user_norm[ui], w_norm).ratio()
+ if sim > best_sim:
+ best_sim = sim; best_u = ui
+ if best_u >= 0 and best_sim >= STRONG:
+ anchors.append((best_u, float(w["start"]), float(w["end"])))
+ next_u = best_u + 1
+
+ # ── Build output ───────────────────────────────────────────────────────
+ out = []
+
+ if not anchors:
+ # No reliable whisper structure — distribute all description lines
+ # across the vocal regions in order. Best-effort but never skips.
+ return _distribute_in_vocal_regions(U, vocal_regions, 0.5, audio_end - 0.3)
+
+ # Segment 0: description lines BEFORE the first anchor go in the time
+ # window [0, anchor[0].start], aligned to vocal regions there.
+ first_u, first_start, _ = anchors[0]
+ if first_u > 0 and first_start > 0.6:
+ out.extend(_distribute_in_vocal_regions(
+ U[0:first_u], vocal_regions, 0.0, first_start
+ ))
+
+ # The anchor line itself uses whisper timing.
+ out.append(_build_line(U[first_u], first_start, anchors[0][2]))
+
+ # Middle segments: between each pair of anchors, distribute the lines
+ # between them across vocal regions in that window.
+ for i in range(1, len(anchors)):
+ prev_u, _, prev_end_t = anchors[i - 1]
+ cur_u, cur_start_t, cur_end_t = anchors[i]
+ gap_start = prev_end_t
+ gap_end = cur_start_t
+ between_lines = U[prev_u + 1 : cur_u]
+ if between_lines and gap_end - gap_start > 0.6:
+ out.extend(_distribute_in_vocal_regions(
+ between_lines, vocal_regions, gap_start, gap_end
+ ))
+ out.append(_build_line(U[cur_u], cur_start_t, cur_end_t))
+
+ # Trailing segment: description lines after the last anchor distributed
+ # across the audio's remaining vocal regions.
+ last_u, _, last_end_t = anchors[-1]
+ trailing = U[last_u + 1:]
+ if trailing:
+ end_time = max(audio_end - 0.3, last_end_t + 2.0)
+ if end_time > last_end_t + 0.6:
+ out.extend(_distribute_in_vocal_regions(
+ trailing, vocal_regions, last_end_t, end_time
+ ))
+
+ return out
+
+
+def _build_line(text: str, start: float, end: float) -> dict:
+ """Construct an output line dict with redistributed word timings."""
+ lang = _guess_lang_from_script(text)
+ s = round(float(start), 3)
+ e = round(max(float(end), s + 0.4), 3)
+ return {"start": s, "end": e, "text": text, "lang": lang,
+ "words": _redistribute_words(s, e, text, lang)}
+
+
+def _spread_lines_evenly(lines: list, start: float, end: float) -> list:
+ """Distribute `lines` evenly between [start, end]. Used as a last-resort
+ fallback when whisper produced no usable anchors at all."""
+ if not lines or end <= start: return []
+ slot = (end - start) / len(lines)
+ out = []
+ for k, ul in enumerate(lines):
+ s = start + k * slot
+ e = start + (k + 1) * slot
+ lang = _guess_lang_from_script(ul)
+ out.append({
+ "start": round(s, 3), "end": round(e, 3),
+ "text": ul, "lang": lang,
+ "words": _redistribute_words(s, e, ul, lang),
+ })
+ return out
+
+
+def align_user_lyrics(user_lines: list, whisper_lines: list) -> list:
+ """Legacy: project user lines onto whisper anchors with N-W DP. Kept for
+ reference; the active pipeline uses correct_whisper_with_description()
+ because it preserves whisper's natural timing instead of squeezing all
+ description lines into whatever anchors were found."""
+ from difflib import SequenceMatcher
+ if not user_lines:
+ return whisper_lines
+ if not whisper_lines:
+ return []
+
+ U = [u for u in user_lines if u.strip()]
+ W = whisper_lines
+ nU, nW = len(U), len(W)
+ if nU == 0:
+ return []
+
+ user_norm = [_norm_for_match(u) for u in U]
+ whisper_norm = [_norm_for_match(w.get("text", "")) for w in W]
+
+ # Script of each user line and each whisper line. For multilingual songs
+ # an English user line MUST anchor to an English whisper segment and a Thai
+ # user line MUST anchor to a Thai whisper segment — otherwise the DP forces
+ # a Thai user line onto an English anchor (or vice-versa) and the whole
+ # block of mismatched-language user lines collapses into the wrong region.
+ user_script = [_guess_lang_from_script(u) for u in U]
+ whisper_script = [(w.get("lang") or _guess_lang_from_script(w.get("text", ""))) for w in W]
+
+ def _same_script(a: str, b: str) -> bool:
+ # Coarse equivalence — collapse all Latin-script European languages
+ # together, all CJK together, etc. so e.g. an English user line still
+ # matches a Spanish whisper anchor if that's all we have.
+ LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
+ if a in LATIN and b in LATIN: return True
+ return a == b
+
+ # Similarity matrix (cached lookups via SequenceMatcher). Cross-script
+ # pairs are zeroed so the DP can never anchor across languages.
+ sim = [[0.0] * nW for _ in range(nU)]
+ for i in range(nU):
+ if not user_norm[i]:
+ continue
+ sm = SequenceMatcher(None, user_norm[i], "")
+ sm.set_seq1(user_norm[i])
+ for j in range(nW):
+ if not whisper_norm[j]:
+ continue
+ if not _same_script(user_script[i], whisper_script[j]):
+ continue # different script → can't be the same line
+ sm.set_seq2(whisper_norm[j])
+ sim[i][j] = sm.ratio()
+
+ # Higher threshold prevents the DP from anchoring a user line to a weakly-
+ # similar whisper segment in the wrong region of the song. Weak matches get
+ # interpolated between confident anchors instead, which spreads lyric lines
+ # over the right time window.
+ MATCH_THRESHOLD = 0.35
+ GAP_USER = -0.10 # cost of leaving a user line unmatched
+ GAP_WHISPER = -0.04 # cost of skipping a whisper line
+ SOFT_DIAG = -0.04 # diagonal move with too-low similarity (no match credit)
+
+ # DP table: dp[i][j] = best score aligning U[:i] vs W[:j].
+ dp = [[0.0] * (nW + 1) for _ in range(nU + 1)]
+ for i in range(1, nU + 1):
+ dp[i][0] = dp[i - 1][0] + GAP_USER
+ for j in range(1, nW + 1):
+ dp[0][j] = dp[0][j - 1] + GAP_WHISPER
+
+ for i in range(1, nU + 1):
+ for j in range(1, nW + 1):
+ s = sim[i - 1][j - 1]
+ match_score = dp[i - 1][j - 1] + (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
+ user_gap = dp[i - 1][j] + GAP_USER
+ whisper_gap = dp[i][j - 1] + GAP_WHISPER
+ dp[i][j] = max(match_score, user_gap, whisper_gap)
+
+ # Traceback to recover the matched pairs (user_idx → whisper_idx).
+ matches = {}
+ i, j = nU, nW
+ while i > 0 and j > 0:
+ s = sim[i - 1][j - 1]
+ eff = (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
+ if abs(dp[i][j] - (dp[i - 1][j - 1] + eff)) < 1e-9:
+ if s >= MATCH_THRESHOLD:
+ matches[i - 1] = j - 1
+ i -= 1; j -= 1
+ elif abs(dp[i][j] - (dp[i - 1][j] + GAP_USER)) < 1e-9:
+ i -= 1
+ else:
+ j -= 1
+
+ # Build aligned output: matched lines get the whisper timing; unmatched user
+ # lines get evenly interpolated between their nearest matched neighbours.
+ out = []
+ pending = []
+ last_end = 0.0
+
+ def flush(next_start):
+ if not pending:
+ return
+ n = len(pending)
+ span = max(0.0, next_start - last_end)
+ slot = (span / (n + 1)) if span > 0 else 0.6
+ for k, (pt, pl) in enumerate(pending):
+ s = last_end + (k + 0.5) * slot
+ e = last_end + (k + 1.5) * slot
+ out.append({"start": round(s, 3), "end": round(e, 3),
+ "text": pt, "lang": pl,
+ "words": _redistribute_words(s, e, pt, pl)})
+ pending.clear()
+
+ for ui, u in enumerate(U):
+ if ui in matches:
+ wl = W[matches[ui]]
+ start = float(wl["start"])
+ end = float(wl["end"])
+ lang = wl.get("lang") or _guess_lang_from_script(u)
+ flush(start)
+ out.append({"start": round(start, 3), "end": round(end, 3),
+ "text": u, "lang": lang,
+ "words": _redistribute_words(start, end, u, lang)})
+ last_end = end
+ else:
+ pending.append((u, _guess_lang_from_script(u)))
+
+ if pending:
+ anchor_end = max(last_end + 1.0, float(W[-1]["end"]))
+ flush(anchor_end)
+
+ return out
+
+
+def merge_fragments(lines: list) -> list:
+ """Stitch tiny leftover fragments (e.g. a lone 'The' or a 1-char Thai token)
+ into an adjacent same-language line when they're close in time."""
+ def tiny(ln):
+ if ln["lang"] in SPACELESS:
+ return len(ln["text"]) < 4
+ return len(ln["text"].split()) < 2
+
+ out = []
+ for ln in lines:
+ if out and out[-1]["lang"] == ln["lang"]:
+ prev = out[-1]
+ gap = ln["start"] - prev["end"]
+ if gap < 1.0 and (tiny(ln) or tiny(prev)):
+ sep = "" if ln["lang"] in SPACELESS else " "
+ prev["text"] = (prev["text"] + sep + ln["text"]).strip()
+ prev["end"] = ln["end"]
+ prev["words"] = (prev.get("words") or []) + (ln.get("words") or [])
+ continue
+ out.append(ln)
+ return out
+
+
+def split_into_lines(words: list, lang: str) -> list:
+ """Split one (single-language) segment's timed words into short karaoke lines."""
+ if not words:
+ return []
+ spaced = lang not in SPACELESS
+ lines, cur = [], [words[0]]
+ for prev, w in zip(words, words[1:]):
+ brk = (w["start"] - prev["end"]) >= LINE_GAP
+ if not brk and spaced and len(cur) >= LINE_MAX_WORDS:
+ brk = True
+ if not brk and not spaced and sum(len(x["text"]) for x in cur) >= LINE_MAX_CHARS:
+ brk = True
+ if not brk and spaced and len(cur) >= LINE_MIN_WORDS:
+ if prev["text"].endswith(PUNCT_END):
+ brk = True
+ else:
+ head = w["text"][:1]
+ if (head.isupper() and not head.isdigit()
+ and w["text"] not in ("I", "I'm", "I'll", "I've", "I'd", "I’m", "I’ll", "I’ve", "I’d")):
+ brk = True
+ if brk:
+ line = _emit(cur, lang)
+ if line:
+ lines.append(line)
+ cur = [w]
+ else:
+ cur.append(w)
+ line = _emit(cur, lang)
+ if line:
+ lines.append(line)
+ return lines
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--audio", required=True)
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--language", default=None)
+ ap.add_argument("--gpu", type=int, default=None)
+ ap.add_argument("--model", default="large-v3")
+ ap.add_argument("--no-demucs", action="store_true")
+ ap.add_argument("--no-vad", action="store_true",
+ help="disable Silero VAD filter inside Whisper (transcribe full audio)")
+ ap.add_argument("--no-vocal-gapfill", action="store_true",
+ help="distribute gap-filled description lines evenly instead of snapping them "
+ "to vocal-active regions detected by Silero VAD")
+ ap.add_argument("--progress", default=None, help="path to write live progress JSON")
+ ap.add_argument("--user-lyrics", default=None,
+ help="path to a text file with one lyric line per line; the pipeline will "
+ "ALIGN these exact lines to the audio instead of producing its own text")
+ args = ap.parse_args()
+
+ global _PROGRESS_PATH
+ _PROGRESS_PATH = args.progress
+
+ if not os.path.isfile(args.audio):
+ log(f"audio not found: {args.audio}")
+ sys.exit(2)
+
+ write_progress(3, "Starting")
+
+ # GPU pinning must happen before torch is imported by whisperx.
+ if args.gpu is not None:
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
+
+ vocals_path = args.audio
+ used_demucs = False
+ if not args.no_demucs:
+ write_progress(8, "Separating vocals")
+ sep = isolate_vocals(args.audio, args.gpu)
+ if sep:
+ vocals_path = sep
+ used_demucs = True
+
+ write_progress(40, "Loading model")
+ from faster_whisper import WhisperModel, decode_audio
+ from faster_whisper.vad import get_speech_timestamps, VadOptions
+ from collections import defaultdict
+ import gc
+
+ SR = 16000
+ audio = decode_audio(vocals_path, sampling_rate=SR)
+
+ def is_oom(e):
+ s = str(e).lower()
+ return "out of memory" in s or "cuda failed" in s or "cublas" in s
+
+ def overlap_ratio(a, b):
+ o = min(a["end"], b["end"]) - max(a["start"], b["start"])
+ if o <= 0:
+ return 0.0
+ return o / max(1e-6, min(a["end"] - a["start"], b["end"] - b["start"]))
+
+ # Full multilingual transcription on a given device/precision. Raises on OOM
+ # so the caller can retry on a lighter config (cuda/fp16 → cuda/int8 → cpu).
+ #
+ # Strategy that handles bilingual duets WITHOUT skipping verses: transcribe the
+ # WHOLE song once per candidate language (full recall + sentence context), then
+ # for every time region keep whichever language's transcription is the most
+ # confident. English regions win in the English pass, Thai regions win in the
+ # Thai pass — nothing is dropped and each part is in its own script.
+ def transcribe_all(dev, ct):
+ log(f"[fw] loading {args.model} on {dev}/{ct}")
+ model = WhisperModel(args.model, device=dev, compute_type=ct)
+ try:
+ # ── Candidate languages: detect across several windows of the song ──
+ write_progress(46, "Detecting languages")
+ if args.language:
+ cands = [args.language]
+ else:
+ votes = defaultdict(float)
+ win = 30 * SR
+ positions = list(range(0, max(1, len(audio) - win + 1), max(win // 2, 1)))[:12] or [0]
+ for pos in positions:
+ sl = audio[pos:pos + win]
+ if len(sl) < SR:
+ continue
+ try:
+ lang, prob, _ = model.detect_language(sl, language_detection_segments=1)
+ except Exception as e:
+ if is_oom(e):
+ raise
+ lang, prob = None, 0.0
+ if lang and prob >= 0.5:
+ votes[lang] += prob
+ if not votes:
+ cands = ["en"]
+ else:
+ ranked = sorted(votes, key=votes.get, reverse=True)
+ top = votes[ranked[0]]
+ # Keep languages with ≥25% of the top vote mass (drops flukes).
+ cands = [l for l in ranked if votes[l] >= 0.25 * top][:3]
+ log(f"[lang] candidates={cands}")
+
+ # ── One full-song pass per candidate language ──────────────────────
+ # Loose VAD pass: drops obvious instrumental stretches but keeps soft
+ # sung vocals (threshold 0.20 vs default 0.5). Without it, Whisper
+ # invents lyrics over the intro/outro music. With it tuned too high
+ # it drops legitimate quiet singing — we erred on the loose side after
+ # users reported missing verses in the middle of long songs.
+ VAD_PARAMS = {
+ "threshold": 0.20,
+ "min_speech_duration_ms": 200,
+ "min_silence_duration_ms": 350,
+ "speech_pad_ms": 250,
+ }
+ # Common Whisper hallucinations on silence / music. If a segment IS
+ # one of these phrases (no extra content), it's a hallucination
+ # regardless of how confident the model was.
+ HALLUCINATIONS = {
+ "thank you", "thanks for watching", "thank you for watching",
+ "subscribe", "please subscribe", "like and subscribe",
+ "music", "[music]", "(music)", "♪", "♫",
+ "you", ".", "..", "...", "thank you.",
+ }
+ segs_all = []
+ for ci, L in enumerate(cands):
+ write_progress(50 + int(40 * ci / max(1, len(cands))), "Transcribing")
+ seg_iter, _ = model.transcribe(
+ audio, language=L, word_timestamps=True, beam_size=5,
+ vad_filter=(not args.no_vad), vad_parameters=VAD_PARAMS,
+ condition_on_previous_text=False,
+ no_speech_threshold=0.70,
+ log_prob_threshold=-1.4,
+ )
+ for s in seg_iter:
+ # Drop clear non-speech and low-confidence hallucinations on
+ # instrumental sections, but keep genuinely-sung (lower-conf) lines.
+ if getattr(s, "no_speech_prob", 0.0) > 0.70:
+ continue
+ if getattr(s, "avg_logprob", 0.0) < -1.4:
+ continue
+ text = (s.text or "").strip()
+ if not text:
+ continue
+ # Drop the well-known Whisper boilerplate hallucinations.
+ if text.lower().strip(".,!? ") in HALLUCINATIONS:
+ continue
+ # Drop "compression ratio" gibberish — pathological repeats.
+ if getattr(s, "compression_ratio", 1.0) > 2.4:
+ continue
+ segs_all.append({
+ "start": float(s.start), "end": float(s.end), "lang": L,
+ "score": float(getattr(s, "avg_logprob", -5.0)),
+ "text": text, "words": list(s.words or []),
+ })
+
+ # ── Resolve overlaps using OUTPUT SCRIPT as the language signal ─────
+ # avg_logprob alone is unreliable (the Thai pass can "win" English
+ # regions yet output Latin). The script actually produced is the
+ # truth: a non-Latin-language pass that emitted Latin text is a
+ # mis-forced English region — drop it. Native non-Latin script wins
+ # overlaps so Thai regions never get the romanised English version.
+ def nonlatin_frac(t):
+ letters = [c for c in t if c.isalpha()]
+ if not letters:
+ return 0.0
+ return sum(1 for c in letters if not ("a" <= c.lower() <= "z")) / len(letters)
+
+ kept = []
+ for s in segs_all:
+ nl = nonlatin_frac(s["text"])
+ s["native"] = 1 if nl >= 0.5 else 0
+ if s["lang"] in NONLATIN_LANGS and nl < 0.3:
+ continue # Thai (etc.) pass that produced Latin = mis-forced English
+ kept.append(s)
+
+ kept.sort(key=lambda x: (x["native"], x["score"]), reverse=True)
+ accepted = []
+ for s in kept:
+ if any(overlap_ratio(s, a) > 0.4 for a in accepted):
+ continue
+ accepted.append(s)
+ accepted.sort(key=lambda x: x["start"])
+
+ dur = defaultdict(float)
+ for s in accepted:
+ dur[s["lang"]] += s["end"] - s["start"]
+ dominant = max(dur, key=dur.get) if dur else (cands[0] if cands else "en")
+ trusted = set(dur.keys()) or set(cands)
+
+ # ── Build karaoke lines ────────────────────────────────────────────
+ lines = []
+ for s in accepted:
+ compact = s["text"].replace(" ", "")
+ if len(compact) >= 8 and len(set(compact)) <= 1: # degenerate "ㄷㄷㄷ"
+ continue
+ words = []
+ for w in s["words"]:
+ if w.start is None or w.end is None:
+ continue
+ tok = (w.word or "").strip()
+ if not tok:
+ continue
+ words.append({"start": round(float(w.start), 3),
+ "end": round(float(w.end), 3), "text": tok})
+ if words:
+ lines += split_into_lines(words, s["lang"])
+ else:
+ lines.append({"start": round(s["start"], 3), "end": round(s["end"], 3),
+ "text": s["text"], "lang": s["lang"], "words": []})
+ return lines, dominant, trusted
+ finally:
+ del model
+ gc.collect()
+
+ all_lines, dominant, trusted, last_err = [], "en", set(), None
+ for dev, ct in [("cuda", "float16"), ("cuda", "int8"), ("cpu", "int8")]:
+ try:
+ all_lines, dominant, trusted = transcribe_all(dev, ct)
+ break
+ except Exception as e:
+ last_err = e
+ if is_oom(e):
+ log(f"[fw] {dev}/{ct} ran out of memory; retrying lighter")
+ continue
+ raise
+ else:
+ raise last_err if last_err else RuntimeError("transcription failed")
+
+ all_lines.sort(key=lambda ln: ln["start"])
+ all_lines = merge_fragments(all_lines)
+
+ # If the uploader provided lyrics in the song description, ALIGN those exact
+ # lines to the audio (using the whisper timing) instead of using the noisier
+ # whisper text. The transcription pass still ran — it's what provides the
+ # anchoring timestamps the user lines snap to.
+ source = "faster-whisper"
+ if args.user_lyrics and os.path.isfile(args.user_lyrics):
+ write_progress(92, "Syncing description lyrics")
+ try:
+ user_lines = [l.strip() for l in open(args.user_lyrics, encoding="utf-8")
+ .read().splitlines() if l.strip()]
+ except Exception as e:
+ log(f"[user-lyrics] read failed ({e})")
+ user_lines = []
+ if user_lines:
+ # Hybrid alignment: whisper-anchored where whisper heard the song,
+ # description-filled where whisper missed. Gap-filled lines snap
+ # to vocal-active moments detected by Silero VAD so they sit on
+ # actual singing instead of drifting across instrumental beats.
+ audio_duration = len(audio) / SR
+ vocal_regions = []
+ if not args.no_vocal_gapfill:
+ try:
+ vad_opts = VadOptions(threshold=0.20,
+ min_speech_duration_ms=400,
+ min_silence_duration_ms=500,
+ speech_pad_ms=120)
+ raw = get_speech_timestamps(audio, vad_opts)
+ vocal_regions = [(r["start"] / SR, r["end"] / SR) for r in raw]
+ log(f"[vad] {len(vocal_regions)} vocal regions detected")
+ except Exception as e:
+ log(f"[vad] failed ({e}); falling back to even spread in gaps")
+ else:
+ log("[vad] vocal-region gap-fill disabled by admin toggle")
+ corrected = correct_whisper_with_description(
+ all_lines, user_lines, audio_duration, vocal_regions
+ )
+ if corrected:
+ all_lines = corrected
+ source = "description-aligned"
+ log(f"[user-lyrics] aligned: description={len(user_lines)} "
+ f"output={len(all_lines)} duration={audio_duration:.1f}s")
+
+ write_progress(95, "Finishing")
+
+ payload = {
+ "version": 1,
+ "language": dominant,
+ "source": source,
+ "model": args.model,
+ "demucs": used_demucs,
+ "multilingual": True,
+ "lines": all_lines,
+ }
+
+ out_dir = os.path.dirname(args.out)
+ if out_dir:
+ os.makedirs(out_dir, exist_ok=True)
+ with open(args.out, "w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False)
+ log(f"[done] wrote {len(payload['lines'])} lines ({sorted(trusted)}) -> {args.out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/resources/views/admin/backup.blade.php b/resources/views/admin/backup.blade.php
new file mode 100644
index 0000000..aea9cbe
--- /dev/null
+++ b/resources/views/admin/backup.blade.php
@@ -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')
+
+
+@if(session('success'))
+
+
+ {{ session('success') }}
+
+
+@endif
+
+@if($errors->any())
+
+
+ {{ $errors->first() }}
+
+
+@endif
+
+
+
+
+
+
+
+ Export users & settings
+ Downloads a JSON file containing all user accounts and system settings. Does not include media files.
+
+
+
+
+
+
+ Restore users & settings
+ Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.
+
+
+
+
+
+
+
+
+@endsection
diff --git a/resources/views/admin/gpu.blade.php b/resources/views/admin/gpu.blade.php
new file mode 100644
index 0000000..7bb56eb
--- /dev/null
+++ b/resources/views/admin/gpu.blade.php
@@ -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')
+
+
+@if(session('success'))
+
+
+ {{ session('success') }}
+
+
+@endif
+
+@if($errors->any())
+
+
+ {{ $errors->first() }}
+
+
+@endif
+
+
+@endsection
+
+@section('scripts')
+
+@endsection
diff --git a/resources/views/admin/layout.blade.php b/resources/views/admin/layout.blade.php
index 589c3f9..befb78b 100644
--- a/resources/views/admin/layout.blade.php
+++ b/resources/views/admin/layout.blade.php
@@ -597,7 +597,23 @@
System
- Settings
+ AI / LLM
+
+
+ Lyrics Pipeline
+
+
+ GPU Accelerator
+
+
+ NAS Storage
+
+
+ Backup & Restore
@@ -607,10 +623,6 @@
class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}">
Error Logs
-
- NAS Storage
-
@endif
diff --git a/resources/views/admin/lyrics.blade.php b/resources/views/admin/lyrics.blade.php
new file mode 100644
index 0000000..07316b3
--- /dev/null
+++ b/resources/views/admin/lyrics.blade.php
@@ -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')
+
+
+@if(session('success'))
+
+
+ {{ session('success') }}
+
+
+@endif
+
+
+@endsection
diff --git a/resources/views/admin/nas-storage.blade.php b/resources/views/admin/nas-storage.blade.php
index 18ee93d..fb9f985 100644
--- a/resources/views/admin/nas-storage.blade.php
+++ b/resources/views/admin/nas-storage.blade.php
@@ -1,23 +1,356 @@
@extends('admin.layout')
@section('title', 'NAS Storage')
+@section('page_title', 'NAS Storage')
+
+@section('extra_styles')
+@include('admin.partials.settings-styles')
+
+@endsection
@section('content')
-
- @include('nas-file-manager::file-manager', [
- 'nodes' => $nodes,
- 'canEdit' => true,
- 'title' => 'NAS Storage Browser',
- ])
+@if(session('success'))
+
+
+ {{ session('success') }}
+
+
+@endif
+
+{{-- ── NAS Settings ─────────────────────────────────────────── --}}
+
+
+
+
+
+
+ Use NAS as primary storage
+
+ When enabled, uploads go directly to the NAS — no permanent local copy is kept.
+ Files are stored at users/{username}/videos/{title-slug}/ on the NAS share.
+ When disabled, all files are served from local disk using the same directory schema.
+ Disabling NAS will prompt you to migrate files or start fresh.
+
+
+
+ @if($settings['nas_sync_enabled'] === 'true')
+
+ Disable NAS
+
+ @else
+ NAS is disabled. Re-enabling is handled by the system once a NAS endpoint is reachable.
+ @endif
+
+
+
+ @if($settings['nas_sync_enabled'] === 'true')
+
+
+ Repair stuck files
+
+ 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.
+
+
+
+
+
+ Scan
+
+
+ Fix All
+
+
+
+
+
+ @endif
+
+
+
+
+{{-- ── NAS File Browser ──────────────────────────────────────── --}}
+
+
+
+ @include('nas-file-manager::file-manager', [
+ 'nodes' => $nodes,
+ 'canEdit' => true,
+ 'title' => 'NAS Storage Browser',
+ ])
+
+
+
+{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
+
+
+
+
+
Disable NAS Storage
+
All your files currently live on the NAS. Choose what to do before disabling:
+
+
+
+
+
+ Copy all files to local disk
+
+
Downloads every video, thumbnail, avatar, and banner from the NAS to storage/app/users/…. Same directory structure — everything keeps working. May take a while.
+
+
+
+
+ Delete all media, start fresh
+
+
Removes all videos, thumbnails, playlists, comments, and posts. User accounts are kept. Nothing is downloaded.
+
+
+
+
+ Cancel
+ Continue →
+
+
+
+
+
Migrating files to local disk…
+
Starting…
+
+
0 / 0
+
+
Migration complete! NAS has been disabled. Reload the page to continue.
+
Reload Page
+
+
+
+
+
+
Delete all media?
+
This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. This cannot be undone.
+
Type DELETE to confirm:
+
+
+
+ Cancel
+ Delete & Disable NAS
+
+
+
+
@endsection
@section('scripts')
+
@endsection
diff --git a/resources/views/admin/partials/settings-styles.blade.php b/resources/views/admin/partials/settings-styles.blade.php
new file mode 100644
index 0000000..6b2f6a8
--- /dev/null
+++ b/resources/views/admin/partials/settings-styles.blade.php
@@ -0,0 +1,124 @@
+{{-- Shared styles for all admin settings-style pages: GPU, NAS, Backup, Settings. --}}
+
diff --git a/resources/views/admin/settings.blade.php b/resources/views/admin/settings.blade.php
index 37febe6..1345fc5 100644
--- a/resources/views/admin/settings.blade.php
+++ b/resources/views/admin/settings.blade.php
@@ -1,136 +1,83 @@
@extends('admin.layout')
-@section('title', 'System Settings')
-@section('page_title', 'Settings')
+@section('title', 'AI / LLM Settings')
+@section('page_title', 'AI / LLM Settings')
@section('extra_styles')
+@include('admin.partials.settings-styles')
@endsection
@section('content')
@if(session('success'))
@@ -149,279 +96,177 @@
@endif
-
+ {{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
+ so they're always reachable on both mobile and desktop. --}}
@@ -542,9 +544,14 @@ if (!window._slideshowDlInit) {
// the selected language track (window._ytpTrackId; 0 = primary).
var vizOn = localStorage.getItem('audioBarsOn') === '1';
var trackId = window._ytpTrackId || 0;
+ // Burn lyrics into the download only when the viewer has them enabled AND
+ // they actually exist for the current track (the toggle button is visible).
+ var lyrBtn = document.getElementById('ytpLyricsBtn');
+ var lyrOn = localStorage.getItem('ytpLyricsOn') === '1' && lyrBtn && lyrBtn.style.display !== 'none';
var _p = [];
if (vizOn) _p.push('visualizer=1');
if (trackId) _p.push('track=' + trackId);
+ if (lyrOn) _p.push('lyrics=1');
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=
diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php
index 89fa933..7914639 100644
--- a/resources/views/components/video-card.blade.php
+++ b/resources/views/components/video-card.blade.php
@@ -87,10 +87,10 @@ $sizeClasses = match($size) {
@endif
@if($video)
- @if($video->type && $video->type !== 'generic')
+ @if($video->type)
- {{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }}
+ {{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
·
@endif
@@ -269,6 +269,7 @@ $sizeClasses = match($size) {
+@once
+@endonce
@once
@endonce
diff --git a/resources/views/components/video-player.blade.php b/resources/views/components/video-player.blade.php
index a4337a8..132b535 100644
--- a/resources/views/components/video-player.blade.php
+++ b/resources/views/components/video-player.blade.php
@@ -134,6 +134,12 @@
+ {{-- Mini player toggle — desktop-only, persisted in localStorage --}}
+
Playback speed
@@ -818,6 +824,19 @@ video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
function initSource() {
video.muted = true;
video.autoplay = true;
+ /* Resume handoff from the mini player: ?t=
seeks the video to that
+ position once metadata is ready. One-shot — only the initial load. */
+ try {
+ var _qs = new URLSearchParams(location.search);
+ var _t = parseInt(_qs.get('t') || '0', 10);
+ if (_t > 0) {
+ video.addEventListener('loadedmetadata', function () {
+ if (_t < (video.duration || Infinity)) {
+ try { video.currentTime = _t; } catch (e) {}
+ }
+ }, { once: true });
+ }
+ } catch (e) {}
if (HLS_URL && window.Hls && Hls.isSupported()) {
window._ytpHls = new Hls({ startLevel: -1 });
// Register MANIFEST_PARSED before loadSource to avoid cache race condition
@@ -1077,6 +1096,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
+ /* Sync the mini-player toggle row's label to the current preference each
+ time the gear opens, so reloading the page or toggling from the music
+ player keeps the indicator honest. */
+ if (open) {
+ const miniRow = document.getElementById('ytpMiniToggleRow');
+ if (miniRow) {
+ const v = miniRow.querySelector('.ytp-settings-val');
+ const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
+ if (v) v.textContent = on ? 'On' : 'Off';
+ }
+ }
showControls();
clearTimeout(hideTimer); // keep controls visible while settings open
});
@@ -1370,10 +1400,11 @@ function init() {
largePlay.classList.add('visible');
showControls();
- // Scroll-based mini player: watch when #ytpWrap leaves the viewport
- if (window.IntersectionObserver && window._miniPlayer) {
- /* On mobile, #main is the scroll container; on desktop the window scrolls */
- var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null;
+ // Scroll-based mini player: watch when #ytpWrap leaves the viewport.
+ // Desktop-only — on mobile the fixed bottom-nav + locked scroll model
+ // make a floating overlay disruptive.
+ if (window.IntersectionObserver && window._miniPlayer && window.innerWidth > 768) {
+ var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
new IntersectionObserver(function (entries) {
var e0 = entries[0];
@@ -1381,7 +1412,8 @@ function init() {
Using !video.paused was unreliable: autoplay fires asynchronously and the
initial IntersectionObserver callback could run before HLS.js even attaches,
teleporting the element before it ever played in the main player. */
- if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && !window._miniPlayer.isNavMode()) {
+ var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
+ if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && miniAllowed && !window._miniPlayer.isNavMode()) {
_scrollMiniOn = true;
window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(),
@@ -1392,6 +1424,12 @@ function init() {
window._miniPlayer.deactivateScroll();
}
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
+
+ /* User clicked the X on the mini while still on the video page —
+ reset the flag so a subsequent scroll-away re-activates it. */
+ window.addEventListener('miniplayer:scroll-closed', function () {
+ _scrollMiniOn = false;
+ });
}
}
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index f5c2aba..d874b82 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -16,6 +16,118 @@
+ {{-- Image cropper assets — must be in the layout head (not inside the
+ x-image-cropper component) because page-level uses of the cropper
+ render those styles inside #main, which the SPA navigation later
+ wipes via innerHTML swap. The layout-level modals (upload,
+ sports-match) would then render their cropper overlays unstyled. --}}
+
+
+
+@include('user.partials.channel.styles.desktop')
+@include('user.partials.channel.styles.mobile')
@endsection
@section('content')
@@ -1359,6 +213,71 @@ $headerSocialMap = [
@endif
@endif
+
+ @if($isOwner && !$preview)
+ {{-- Owner manage dropdown: consolidates Edit channel / 2FA / Log Out All
+ so it can sit inline with the horoscope strip instead of taking up
+ a whole row beneath it. --}}
+
+
+
+ Manage
+
+
+
+
+
+
+ {{-- Primary CTA + visitor-preview icon, inline alongside Manage. --}}
+
+
+ Upload
+
+
+
+
+ @endif
@if($socialLinks->isNotEmpty())
@foreach($socialLinks as $slink)
@@ -1379,34 +298,10 @@ $headerSocialMap = [
@endif
- {{-- Action buttons (owner only) --}}
- @if($isOwner && !$preview)
-
-
-
- Edit channel
-
-
-
- Upload
-
-
-
- {{ $user->two_factor_enabled ? '2FA On' : '2FA Off' }}
-
-
-
- Log Out All
-
-
-
-
-
+ {{-- Owner action row moved up into the horoscope strip — Edit/2FA/Logout
+ collapsed into Manage; Upload + Preview sit inline next to it. --}}
+ @if($isOwner && !$preview)
{{-- Logout All Devices Modal — appended to body on open to escape stacking context --}}
@@ -1873,6 +768,7 @@ $headerSocialMap = [
@else
Public
@endif
+ · {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
@endforeach
@@ -3106,7 +2002,7 @@ function saveNotifPref(key, value) {
id="avatar"
:width="300"
:height="300"
- shape="circle"
+ shape="square"
folder="avatars"
:filename="'avatar_' . $user->id"
callback="onAvatarSaved"
diff --git a/resources/views/user/partials/channel/styles/desktop.blade.php b/resources/views/user/partials/channel/styles/desktop.blade.php
new file mode 100644
index 0000000..e79a7fb
--- /dev/null
+++ b/resources/views/user/partials/channel/styles/desktop.blade.php
@@ -0,0 +1,1169 @@
+{{-- ===========================================================
+ Channel page — DESKTOP / BASE styles
+ This file is the foundation: it establishes the layout that
+ applies on every viewport (desktop sizes natively, and acts
+ as the base that mobile rules override on small screens).
+ -----------------------------------------------------------
+ • Edit here for: typography, colors, layout structure,
+ desktop-specific scaling, hero/banner sizing, tab styles.
+ • Do NOT add @media (max-width: ...) overrides here — those
+ live in partials/channel/styles.mobile.blade.php so the
+ two viewports remain independent.
+ =========================================================== --}}
+
diff --git a/resources/views/user/partials/channel/styles/mobile.blade.php b/resources/views/user/partials/channel/styles/mobile.blade.php
new file mode 100644
index 0000000..c31443c
--- /dev/null
+++ b/resources/views/user/partials/channel/styles/mobile.blade.php
@@ -0,0 +1,150 @@
+{{-- ===========================================================
+ Channel page — MOBILE overrides
+ This file owns every mobile/touch-screen rule. All selectors
+ are wrapped in @media (max-width: 768px) or smaller so they
+ cannot affect desktop. Editing here is safe.
+ -----------------------------------------------------------
+ • Edit here for: phone layout, tap-target sizing, mobile
+ hero, mobile tabs, anything that should only apply on
+ small viewports.
+ • Add new mobile rules INSIDE one of the @media blocks below.
+ =========================================================== --}}
+
diff --git a/resources/views/videos/create.blade.php b/resources/views/videos/create.blade.php
index fc6429f..4ee9669 100644
--- a/resources/views/videos/create.blade.php
+++ b/resources/views/videos/create.blade.php
@@ -823,11 +823,18 @@
const formData = new FormData(this);
if (_isAudioSubmitC) {
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
- for (const [tid, files] of Object.entries(_cSlidesData)) {
- if (tid === 'ct1') continue;
- const n = parseInt(tid.replace('ce', ''));
- files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
- }
+ // Walk extra-track sections in DOM order so slide index matches
+ // the positional `extra_track_files[]` order the backend iterates.
+ var extraEls = document.querySelectorAll('#ltac-extra .ltac-item');
+ extraEls.forEach(function (el, domIdx) {
+ var m = el.id.match(/^ltac-(e\d+)$/);
+ if (!m) return;
+ var tid = 'c' + m[1];
+ var files = _cSlidesData[tid] || [];
+ files.forEach(function (f) {
+ formData.append('extra_track_slides[' + domIdx + '][]', f);
+ });
+ });
}
const xhr = new XMLHttpRequest();
xhr.timeout = 0; // no timeout — unlimited file size
diff --git a/resources/views/videos/partials/audio-player.blade.php b/resources/views/videos/partials/audio-player.blade.php
index 53cddc7..dea5ae3 100644
--- a/resources/views/videos/partials/audio-player.blade.php
+++ b/resources/views/videos/partials/audio-player.blade.php
@@ -3,9 +3,18 @@
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
- $slideUrls = $video->slides->count() > 1
- ? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
- : [];
+
+ // Per-track slide URL map. Key "0" = primary, other keys = audio_track_id.
+ // Slide sharing rule: if a track has no slides, fall back to primary, then to any
+ // other track that has them (Video::slidesForTrack handles the resolution).
+ $slideMap = ['0' => $video->slidesForTrack(null)
+ ->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()];
+ foreach ($video->audioTracks as $_t) {
+ $slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
+ ->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all();
+ }
+ // Initial set (primary) — used for first paint before JS runs.
+ $slideUrls = $slideMap['0'] ?? [];
// Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language)
$primaryLang = $video->language ?? 'default';
$allLangData = \App\Data\Languages::all();
@@ -32,6 +41,16 @@
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
]));
$hasMultipleTracks = $allAudioTracks->count() > 1;
+
+ // Synced lyrics, embedded inline (no separate request). Keyed by track id; "0" = primary.
+ // Local mirror only — must not block page render on NAS I/O.
+ $lyricsSvc = app(\App\Services\NasSyncService::class);
+ $inlineLyrics = ['0' => $lyricsSvc->getLocalLyrics($video, null)];
+ foreach ($video->audioTracks as $lt) {
+ $inlineLyrics[(string) $lt->id] = $lyricsSvc->getLocalLyrics($video, $lt);
+ }
+ $lyricsOwner = \Illuminate\Support\Facades\Auth::id() === $video->user_id;
+ $lyricsAllowed = \App\Models\Setting::get('lyrics_enabled', 'true') === 'true';
@endphp
@@ -47,6 +66,27 @@
{{-- Bars canvas overlay --}}
+ @if($lyricsAllowed)
+ {{-- Synced lyrics overlay — one line at a time, anchored to the bottom --}}
+
+
+ {{-- Live lyrics-generation progress (owner) --}}
+
+
+
+ 🎤
+ Generating lyrics…
+ 0%
+
+
+
+
+ @endif
+
{{-- Gradient --}}
@@ -146,6 +186,31 @@
Normal
+ {{-- Mini player toggle — desktop-only, persisted in localStorage --}}
+
+ @if($lyricsOwner && $lyricsAllowed)
+ {{-- Owner-only: generate/regenerate and edit lyrics live inside the gear so
+ they're always reachable on mobile and don't crowd the control bar. --}}
+
+
+
+ @endif
@@ -166,6 +231,15 @@
+ @if($lyricsAllowed)
+ {{-- Lyrics toggle (hidden until lyrics are available) --}}
+
+
+
+ @endif
+
+ {{-- Owner lyrics generate/regenerate button lives in video-actions (next to Edit), not here. --}}
+
{{-- Bars visualiser toggle --}}
@@ -191,8 +265,48 @@
{{-- Hidden audio element --}}
+@if($lyricsOwner && $lyricsAllowed)
+{{-- Lyrics editor modal (owner) — lives outside the player box --}}
+
+
+
+ Edit Lyrics
+
+
+
Fix any misspelled words. Timing is preserved for lines you don't change.
+
+
+ Cancel
+ Save lyrics
+
+
+
+@endif
+
{{-- ══ CSS ══ --}}
@@ -561,6 +751,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
+ /* Sync the mini-player toggle row's label to the current preference each
+ time the gear opens, so reloading the page or changing it from another
+ player keeps the indicator honest. */
+ if (open) {
+ const miniRow = document.getElementById('ytpMiniToggleRow');
+ if (miniRow) {
+ const v = miniRow.querySelector('.ytp-settings-val');
+ const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
+ if (v) v.textContent = on ? 'On' : 'Off';
+ }
+ }
clearTimeout(hideTimer);
});
document.addEventListener('click', e => {
@@ -654,6 +855,12 @@ if (langBtn && langPopup) {
// Update download links
const dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
+
+ // Show this track's lyrics (from inline data)
+ if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
+
+ // Swap the slideshow to this track's slides (with server-side fallback).
+ if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
});
});
@@ -957,8 +1164,12 @@ function stopBars() {
}
// ── Crossfade slideshow ───────────────────────────────────────
-// Variables hoisted outside the if-block so the SPA update hook can access them
-const SLIDE_URLS = @json($slideUrls);
+// Variables hoisted outside the if-block so the SPA update hook can access them.
+// SLIDE_MAP is keyed by track id ("0" = primary). Each entry already encodes the
+// fallback decided server-side by Video::slidesForTrack(), so a track with no
+// slides of its own gets the primary's (or a sibling's) automatically.
+window._SLIDE_MAP = @json($slideMap);
+const SLIDE_URLS = (window._SLIDE_MAP['0'] || []).slice();
const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB');
let currentSlide = 0;
@@ -997,6 +1208,40 @@ function stopSlideshow() {
slideshowTimer = null;
}
+// Replace the slide list at runtime — called when the user switches audio tracks.
+// Server-side fallback already applied (Video::slidesForTrack), so we trust the
+// incoming list verbatim. Empty → hide slideshow and show the cover image.
+window._applySlidesForTrack = function (trackId) {
+ var key = String(parseInt(trackId, 10) || 0);
+ var next = (window._SLIDE_MAP && window._SLIDE_MAP[key]) ? window._SLIDE_MAP[key].slice() : [];
+ var coverEl = document.getElementById('audioCoverImg');
+ var slideshowEl = document.getElementById('slideshowWrap');
+
+ stopSlideshow();
+ SLIDE_URLS.length = 0;
+ next.forEach(function (u) { SLIDE_URLS.push(u); });
+ slideOrientations.length = 0;
+ for (var i = 0; i < Math.max(SLIDE_URLS.length, 1); i++) slideOrientations.push(false);
+
+ if (SLIDE_URLS.length > 1) {
+ if (coverEl) coverEl.style.display = 'none';
+ if (slideshowEl) slideshowEl.style.display = '';
+ currentSlide = 0; aIsTop = true;
+ if (slideA) { slideA.style.transition = 'none'; slideA.src = SLIDE_URLS[0]; slideA.style.opacity = '1'; slideA.style.zIndex = '2'; }
+ if (slideB) { slideB.style.transition = 'none'; slideB.src = SLIDE_URLS[1] || SLIDE_URLS[0]; slideB.style.opacity = '0'; slideB.style.zIndex = '1'; }
+ requestAnimationFrame(function () { if (slideA) slideA.style.transition = ''; if (slideB) slideB.style.transition = ''; });
+ SLIDE_URLS.forEach(function (url, idx) {
+ var img = new Image();
+ img.onload = function () { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
+ img.src = url;
+ });
+ if (!audio.paused) startSlideshow();
+ } else {
+ if (slideshowEl) slideshowEl.style.display = 'none';
+ if (coverEl) coverEl.style.display = '';
+ }
+};
+
// Always attach listeners; functions guard themselves with SLIDE_URLS.length check
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
@@ -1038,11 +1283,15 @@ if (SLIDE_URLS.length > 1 && slideA) {
// ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
window._audioPlayerUpdate = function(d) {
- var newSlides = (d.slides && d.slides.length > 1) ? d.slides : [];
+ // Adopt the new song's per-track slide map. Fallback already applied server-side.
+ window._SLIDE_MAP = d.slide_map || { '0': (d.slides || []) };
+ var newSlides = (window._SLIDE_MAP['0'] && window._SLIDE_MAP['0'].length > 1)
+ ? window._SLIDE_MAP['0'] : [];
var coverEl = document.getElementById('audioCoverImg');
var slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow();
+ if (window._lyricsStop) window._lyricsStop(); // kill prev song's lyrics polling — don't pile up
SLIDE_URLS.length = 0;
if (newSlides.length > 1) {
@@ -1175,6 +1424,10 @@ window._audioPlayerUpdate = function(d) {
// Update download links
var dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; });
+ // Show this track's lyrics (from inline data)
+ if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
+ // Swap the slideshow to this track's slides (with server-side fallback).
+ if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
});
});
@@ -1200,6 +1453,19 @@ window._audioPlayerUpdate = function(d) {
if (scrubber) scrubber.parentElement.style.left = '0%';
if (timeCur) timeCur.textContent = '0:00';
if (timeDur && d.duration) timeDur.textContent = fmt(d.duration);
+
+ // New song → retarget the lyrics generate/poll endpoints to THIS song.
+ // (The button's URL + poll key were baked in at server render for the first
+ // song; without this, generating after navigation hits the previous song.)
+ if (d.key) {
+ window._ROUTE_KEY = d.key;
+ var _genBtn = document.getElementById('ytpGenLyricsBtn');
+ if (_genBtn) _genBtn.dataset.genUrl = '/videos/' + d.key + '/lyrics/generate';
+ }
+
+ // New song → swap in its lyrics map (embedded in player-data) and show primary
+ window._LYRICS = d.lyrics || {};
+ if (window._lyricsShow) window._lyricsShow(0);
};
// ── Init ─────────────────────────────────────────────────────
@@ -1222,6 +1488,14 @@ audio.addEventListener('playing', function restoreSound() {
audio.addEventListener('loadedmetadata', () => {
timeDur.textContent = fmt(audio.duration);
+ /* Resume handoff from the mini player: ?t= seeks to that position
+ before play starts. ?resume=1 is implicit (the audio player already
+ autoplays); we only need to honor the time. */
+ try {
+ const qs = new URLSearchParams(location.search);
+ const t = parseInt(qs.get('t') || '0', 10);
+ if (t > 0 && t < audio.duration) audio.currentTime = t;
+ } catch (e) {}
const p = audio.play();
if (p) p.catch(() => {
audio.muted = true;
@@ -1229,5 +1503,466 @@ audio.addEventListener('loadedmetadata', () => {
});
});
+// ── Synced lyrics overlay (one line at a time, bottom-anchored) ──
+(function initLyrics() {
+ const overlay = document.getElementById('ytpLyricsOverlay');
+ const panel = document.getElementById('ytpLyricsPanel');
+ const curEl = document.getElementById('ytpLyrCur');
+ const btn = document.getElementById('ytpLyricsBtn');
+ // The owner generate/regenerate button now lives in video-actions (next to
+ // Edit) — look it up lazily so it works regardless of DOM order / SPA swaps.
+ const gb = () => document.getElementById('ytpGenLyricsBtn');
+ if (!overlay || !curEl || !btn) return;
+
+ let lines = [], activeLine = -2, curWordEls = [];
+ let enabled = localStorage.getItem('ytpLyricsOn') === '1';
+ let genPoll = null;
+
+ // Pick an emoji that reflects what the line is ABOUT (first keyword match
+ // wins; more specific themes are listed first). Falls back to a music note.
+ const EMOJI_MAP = [
+ [/\b(heart\s?broken|heartbreak|broke\w*\s+\w*\s*heart|broken heart)\b/i, '💔'],
+ [/\b(love|lovin|loving|adore|sweetheart|my heart|in love|darling)\b/i, '❤️'],
+ [/\b(kiss|kisses|kissing|lips|lip gloss)\b/i, '💋'],
+ [/\b(baby|babe|honey|boo)\b/i, '💕'],
+ [/\b(fire|flame|flames|burn|burning|burns|lit|blaze|blazing)\b/i, '🔥'],
+ [/\b(cry|crying|cried|tears|teardrop|weep|weeping)\b/i, '😢'],
+ [/\b(sad|pain|painful|hurt|hurts|hurting|sorrow|lonely|alone|broken)\b/i, '😔'],
+ [/\b(smile|smiling|happy|happiness|joy|joyful|laugh|laughing)\b/i, '😊'],
+ [/\b(dance|dancing|dancin|groove|sway|move your)\b/i, '💃'],
+ [/\b(party|partying|club|celebrate|turn up|tonight we)\b/i, '🎉'],
+ [/\b(money|cash|rich|dollars?|gold|paid|bands|wealth|diamonds?)\b/i, '💰'],
+ [/\b(king|queen|crown|royal|royalty|throne)\b/i, '👑'],
+ [/\b(night|midnight|tonight|nighttime|dark|darkness|shadows?)\b/i, '🌙'],
+ [/\b(sun|sunshine|sunrise|daylight|bright)\b/i, '☀️'],
+ [/\b(star|stars|shine|shining|shinin|sparkle|glitter|glow)\b/i, '✨'],
+ [/\b(heaven|heavens|sky|skies|clouds?)\b/i, '☁️'],
+ [/\b(rain|raining|rainin|storm|stormy|thunder)\b/i, '🌧️'],
+ [/\b(cold|ice|icy|frozen|freeze|winter|snow)\b/i, '❄️'],
+ [/\b(ocean|sea|waves?|water|river|drown|drowning)\b/i, '🌊'],
+ [/\b(rose|roses|flower|flowers|bloom|petals?)\b/i, '🌹'],
+ [/\b(dream|dreams|dreaming|dreamin|asleep|sleep)\b/i, '💭'],
+ [/\b(fly|flying|flyin|wings|soar|rise|rising)\b/i, '🕊️'],
+ [/\b(rocket|space|moon|sky high|to the moon)\b/i, '🚀'],
+ [/\b(run|running|runnin|ran|escape|escaping|away)\b/i, '🏃'],
+ [/\b(time|clock|hours?|minutes?|forever|seconds?)\b/i, '⏳'],
+ [/\b(god|pray|prayin|prayer|soul|angel|angels|amen|bless)\b/i, '🙏'],
+ [/\b(home|house|hometown)\b/i, '🏠'],
+ [/\b(road|drive|driving|drivin|car|ride|riding|highway)\b/i, '🚗'],
+ [/\b(wild|crazy|insane|reckless|savage|chaos)\b/i, '🤪'],
+ [/\b(strong|power|powerful|stronger|unstoppable)\b/i, '💪'],
+ [/\b(eyes?|look|looking|lookin|stare|staring|gaze)\b/i, '👀'],
+ [/\b(drink|wine|champagne|whiskey|drunk|toast|cheers)\b/i, '🥂'],
+ [/\b(war|fight|fighting|battle|enemy|enemies|trouble)\b/i, '⚔️'],
+ [/\b(devil|hell|sin|sinner|demons?)\b/i, '😈'],
+ [/\b(phone|call|calling|text|message|ring)\b/i, '📱'],
+ [/\b(music|song|sing|singing|singin|melody|beat|rhythm|sound)\b/i, '🎶'],
+ ];
+ function emojiForLine(text) {
+ const s = ' ' + String(text || '') + ' ';
+ for (let i = 0; i < EMOJI_MAP.length; i++) { if (EMOJI_MAP[i][0].test(s)) return EMOJI_MAP[i][1]; }
+ return '🎵';
+ }
+
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+ }
+ function showBtn(show) {
+ btn.style.display = show ? '' : 'none';
+ if (!show) { overlay.style.display = 'none'; btn.classList.remove('active'); }
+ }
+ function applyEnabled() {
+ btn.classList.toggle('active', enabled);
+ overlay.style.display = (enabled && lines.length) ? 'flex' : 'none';
+ }
+ function render(data) {
+ lines = (data && data.lines) ? data.lines : [];
+ activeLine = -2; curWordEls = [];
+ curEl.innerHTML = '';
+ if (!lines.length) { showBtn(false); return; }
+ showBtn(true);
+ applyEnabled();
+ sync(true);
+ }
+ function findLine(t) {
+ let idx = -1;
+ for (let i = 0; i < lines.length; i++) { if (lines[i].start <= t) idx = i; else break; }
+ return idx;
+ }
+ // Render only the single active line, wrapped in cycling decorative emojis.
+ function paintLine(idx) {
+ if (idx < 0 || !lines[idx]) {
+ // No line yet (before the song's first lyric) — hide the panel so
+ // there's no empty black box sitting on the artwork.
+ curEl.innerHTML = '';
+ curWordEls = [];
+ if (panel) panel.style.display = 'none';
+ return;
+ }
+ if (panel) panel.style.display = '';
+ const ln = lines[idx];
+ const words = (ln.words && ln.words.length) ? ln.words : null;
+ const inner = words
+ ? words.map((w, j) => '' + escapeHtml(w.text) + ' ').join(' ')
+ : '' + escapeHtml(ln.text || '') + ' ';
+ // If the LLM decorated the line at generation time, emojis are already
+ // inside ln.text — render it bare without the lead/trail wrap so we
+ // don't double-stack decorations. Old un-decorated lyrics keep the
+ // keyword-emoji fallback (or a baked single emoji from the v1 format).
+ if (ln.decorated) {
+ curEl.innerHTML = inner;
+ } else {
+ const e = (ln.emoji && String(ln.emoji).trim()) || emojiForLine(ln.text);
+ curEl.innerHTML = '' + e + ' ' + inner
+ + '' + e + ' ';
+ }
+ curWordEls = [].slice.call(curEl.querySelectorAll('.ytp-lyrics-word'));
+ }
+ function sync(force) {
+ if (!enabled || !lines.length) return;
+ const t = audio.currentTime || 0;
+ let idx = findLine(t);
+ // During an instrumental gap (well past the current line and the next one
+ // hasn't started) hide the line instead of leaving it frozen on screen.
+ if (idx >= 0 && t > lines[idx].end + 2.5) {
+ const next = lines[idx + 1];
+ if (!next || next.start - t > 0.4) idx = -1;
+ }
+ if (idx !== activeLine || force) {
+ paintLine(idx);
+ activeLine = idx;
+ }
+ if (idx >= 0 && curWordEls.length) {
+ const ws = lines[idx].words || [];
+ curWordEls.forEach((span, j) => { const w = ws[j]; if (w) span.classList.toggle('sung', t >= w.start); });
+ }
+ }
+
+ audio.addEventListener('timeupdate', () => sync(false));
+ audio.addEventListener('seeked', () => sync(true));
+ btn.addEventListener('click', () => {
+ enabled = !enabled;
+ localStorage.setItem('ytpLyricsOn', enabled ? '1' : '0');
+ applyEnabled();
+ if (enabled) sync(true);
+ });
+
+ // Owner-only generate button + live progress panel.
+ const genWrap = document.getElementById('ytpLyrGen');
+ const genBar = document.getElementById('ytpLyrGenBar');
+ const genLabel = document.getElementById('ytpLyrGenLabel');
+ const genPct = document.getElementById('ytpLyrGenPct');
+ let dispPct = 0, targetPct = 0, creepTimer = null;
+
+ function showGen(show, mode) {
+ const b = gb();
+ if (!b) return;
+ b.style.display = show ? '' : 'none';
+ const label = mode === 'regen' ? 'Regenerate lyrics' : 'Generate lyrics';
+ b.title = label;
+ b.classList.toggle('is-regen', mode === 'regen');
+ const lbl = b.querySelector('.genlyr-label');
+ if (lbl) lbl.textContent = label;
+ }
+ function showGenProgress(on) {
+ if (genWrap) genWrap.style.display = on ? 'flex' : 'none';
+ if (on && overlay) overlay.style.display = 'none'; // avoid overlapping the lyrics panel
+ if (!on && creepTimer) { clearInterval(creepTimer); creepTimer = null; }
+ }
+ function setBar(p) {
+ if (genBar) genBar.style.width = p + '%';
+ if (genPct) genPct.textContent = Math.round(p) + '%';
+ }
+
+ // Stop any in-flight generation polling/timers (called on navigation + track
+ // switches so nothing piles up as the user moves between songs).
+ window._lyricsStop = function () {
+ if (genPoll) { clearInterval(genPoll.timer); genPoll = null; }
+ if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
+ showGenProgress(false);
+ };
+
+ // Show lyrics for a track id (0 = primary) straight from the inline map —
+ // no network request. window._LYRICS is keyed by track id as a string.
+ window._lyricsShow = function (trackId) {
+ const tid = parseInt(trackId, 10) || 0;
+ // Switching to a different track → kill a poll left running for another track.
+ if (genPoll && genPoll.track !== tid) window._lyricsStop();
+ const d = (window._LYRICS || {})[String(tid)];
+ if (d && d.status === 'ready' && d.lines && d.lines.length) {
+ // Lyrics exist → show them, keep the owner's "Regenerate" + "Edit" buttons.
+ render(d); showGenProgress(false); showGen(true, 'regen'); showEdit(true); showDelete(true);
+ } else if (d && d.status === 'processing') {
+ // Generation already running (e.g. right after upload) — show the live bar.
+ lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
+ if (genWrap) { if (!genPoll || genPoll.track !== tid) startGenPoll(tid); }
+ else { showGen(false); }
+ } else {
+ lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
+ if (genPoll && genPoll.track === tid) { /* in flight — keep the bar */ }
+ else { showGenProgress(false); showGen(!!gb()); }
+ }
+ };
+
+ // Owner clicks the generate/regenerate button (delegated so it works wherever
+ // the button is rendered and after SPA content swaps) → kick off generation,
+ // then watch the live progress bar in the player.
+ document.addEventListener('click', function (ev) {
+ const b = ev.target.closest && ev.target.closest('#ytpGenLyricsBtn');
+ if (!b) return;
+ ev.preventDefault();
+ // Close the settings panel if the click came from inside the gear menu.
+ const sp = document.getElementById('ytpSettingsPanel');
+ if (sp) sp.classList.remove('open');
+ const track = window._ytpTrackId || 0;
+ const existing = (window._LYRICS || {})[String(track)];
+ const isRegen = !!(existing && existing.status === 'ready' && existing.lines && existing.lines.length);
+ const restore = () => showGen(true, isRegen ? 'regen' : 'gen');
+ const csrfMeta = document.querySelector('meta[name="csrf-token"]');
+ const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
+ const url = b.dataset.genUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics/generate');
+ showGen(false); showGenProgress(true);
+ dispPct = 1; targetPct = 1; setBar(1);
+ if (genLabel) genLabel.textContent = isRegen ? 'Regenerating…' : 'Starting…';
+ if (window.showToast) window.showToast((isRegen ? 'Regenerating' : 'Generating') + ' lyrics…', 'info');
+ fetch(url + '?track=' + track, {
+ method: 'POST',
+ headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.error) { showGenProgress(false); restore(); if (window.showToast) window.showToast(res.error, 'error'); return; }
+ startGenPoll(track);
+ })
+ .catch(() => { showGenProgress(false); restore(); if (window.showToast) window.showToast('Could not start generation.', 'error'); });
+ });
+
+ // Poll the live progress endpoint; creep the bar between polls so it always moves.
+ function startGenPoll(track) {
+ if (genPoll) clearInterval(genPoll.timer);
+ showGen(false); showGenProgress(true);
+ dispPct = Math.max(dispPct, 1); targetPct = Math.max(targetPct, 1);
+ if (genLabel) genLabel.textContent = 'Generating lyrics…';
+ setBar(dispPct);
+ if (creepTimer) clearInterval(creepTimer);
+ creepTimer = setInterval(() => {
+ const soft = Math.min(targetPct + 6, 97);
+ if (dispPct < soft) { dispPct = Math.min(soft, dispPct + 0.6); setBar(dispPct); }
+ }, 220);
+
+ const key = window._ROUTE_KEY || @json($video->getRouteKey());
+ let misses = 0, ticks = 0;
+ const timer = setInterval(() => {
+ // Hard cap (~8 min) so a stuck job can never leave a poll running forever.
+ if (++ticks > 320) { window._lyricsStop(); showGen(!!gb()); return; }
+ fetch('/videos/' + key + '/lyrics/progress?track=' + track, { headers: { 'Accept': 'application/json' } })
+ .then(r => r.json())
+ .then(p => {
+ if (p.status === 'ready') {
+ targetPct = 100; dispPct = 100; setBar(100);
+ clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
+ genPoll = null;
+ fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
+ .then(r => r.json())
+ .then(d => {
+ window._LYRICS = d.lyrics || window._LYRICS;
+ setTimeout(() => {
+ showGenProgress(false);
+ if ((window._ytpTrackId || 0) === track) window._lyricsShow(track); else showGen(true);
+ if (window.showToast) window.showToast('Lyrics ready!', 'success');
+ }, 400);
+ })
+ .catch(() => { showGenProgress(false); window._lyricsShow(track); });
+ } else if (p.status === 'failed') {
+ clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
+ genPoll = null; showGenProgress(false); showGen(true);
+ if (window.showToast) window.showToast('Lyrics generation failed.', 'error');
+ } else if (p.status === 'none') {
+ if (++misses > 6) { clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; } genPoll = null; showGenProgress(false); showGen(true); }
+ } else {
+ misses = 0;
+ if (typeof p.pct === 'number') targetPct = Math.max(targetPct, p.pct);
+ if (genLabel && p.stage) genLabel.textContent = p.stage + '…';
+ }
+ })
+ .catch(() => {});
+ }, 1500);
+ genPoll = { track: track, timer: timer };
+ }
+
+ // ── Lyrics editor (owner) ──────────────────────────────────────────────
+ function showEdit(show) {
+ const e = document.getElementById('ytpEditLyricsBtn');
+ if (e) e.style.display = show ? '' : 'none';
+ }
+ function showDelete(show) {
+ const e = document.getElementById('ytpDeleteLyricsBtn');
+ if (e) e.style.display = show ? '' : 'none';
+ }
+
+ // Owner clicks Delete lyrics (gear menu) → wipe local + NAS copy, reset the
+ // overlay so they can regenerate from scratch. Confirmation via toast-style
+ // inline question; we never use alert/confirm (project rule).
+ document.addEventListener('click', function (ev) {
+ const b = ev.target.closest && ev.target.closest('#ytpDeleteLyricsBtn');
+ if (!b) return;
+ ev.preventDefault();
+ const sp = document.getElementById('ytpSettingsPanel');
+ if (sp) sp.classList.remove('open');
+
+ const track = window._ytpTrackId || 0;
+ const csrfMeta = document.querySelector('meta[name="csrf-token"]');
+ const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
+ const url = b.dataset.delUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics');
+
+ if (window.showToast) window.showToast('Deleting lyrics…', 'info');
+ fetch(url + '?track=' + track, {
+ method: 'DELETE',
+ headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' },
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.error) {
+ if (window.showToast) window.showToast(res.error, 'error');
+ return;
+ }
+ // Clear cached lyrics for this track so the overlay disappears and
+ // the gear button flips back to "Generate" (not "Regenerate").
+ if (window._LYRICS) delete window._LYRICS[String(track)];
+ lines = []; activeLine = -2; curWordEls = [];
+ curEl.innerHTML = '';
+ if (panel) panel.style.display = 'none';
+ overlay.style.display = 'none';
+ showBtn(false); showEdit(false); showDelete(false);
+ showGenProgress(false); showGen(!!gb(), 'gen');
+ if (window.showToast) window.showToast('Lyrics deleted. Click Generate to start fresh.', 'success');
+ })
+ .catch(() => { if (window.showToast) window.showToast('Could not delete lyrics.', 'error'); });
+ });
+ const edOverlay = document.getElementById('lyrEditorOverlay');
+ const edBody = document.getElementById('lyrEditorBody');
+ let edTrack = 0, edLines = [];
+
+ function fmtTime(s) {
+ s = Math.max(0, Math.floor(s || 0));
+ return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
+ }
+ window.openLyricsEditor = function () {
+ const tid = window._ytpTrackId || 0;
+ const d = (window._LYRICS || {})[String(tid)];
+ if (!d || d.status !== 'ready' || !d.lines || !d.lines.length) {
+ if (window.showToast) window.showToast('Generate lyrics first.', 'info');
+ return;
+ }
+ edTrack = tid;
+ edLines = JSON.parse(JSON.stringify(d.lines)); // editable copy (keeps words)
+ edBody.innerHTML = '';
+ edLines.forEach((ln, i) => {
+ const row = document.createElement('div');
+ row.className = 'lyr-editor-row';
+ const time = document.createElement('span');
+ time.className = 'lyr-editor-time';
+ time.textContent = fmtTime(ln.start);
+ time.title = 'Jump to this line';
+ time.addEventListener('click', () => { try { audio.currentTime = ln.start; audio.play().catch(()=>{}); } catch(e){} });
+ const inp = document.createElement('input');
+ inp.className = 'lyr-editor-input';
+ inp.type = 'text';
+ inp.value = ln.text || '';
+ inp.addEventListener('input', () => { edLines[i].text = inp.value; });
+ row.appendChild(time); row.appendChild(inp);
+ edBody.appendChild(row);
+ });
+ edOverlay.style.display = 'flex';
+ };
+ function closeEditor() { if (edOverlay) edOverlay.style.display = 'none'; }
+ if (edOverlay) {
+ document.getElementById('lyrEditorClose').addEventListener('click', closeEditor);
+ document.getElementById('lyrEditorCancel').addEventListener('click', closeEditor);
+ edOverlay.addEventListener('click', e => { if (e.target === edOverlay) closeEditor(); });
+ document.getElementById('lyrEditorSave').addEventListener('click', () => {
+ const saveBtn = document.getElementById('lyrEditorSave');
+ saveBtn.disabled = true;
+ const csrfMeta = document.querySelector('meta[name="csrf-token"]');
+ const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
+ const key = window._ROUTE_KEY || @json($video->getRouteKey());
+ fetch('/videos/' + key + '/lyrics/save', {
+ method: 'POST',
+ headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ track: edTrack, lines: edLines })
+ })
+ .then(r => r.json())
+ .then(res => {
+ saveBtn.disabled = false;
+ if (res.error) { if (window.showToast) window.showToast(res.error, 'error'); return; }
+ // Reload the fresh (re-timed) lyrics and re-render.
+ fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
+ .then(r => r.json())
+ .then(d => {
+ window._LYRICS = d.lyrics || window._LYRICS;
+ if ((window._ytpTrackId || 0) === edTrack) window._lyricsShow(edTrack);
+ })
+ .catch(() => {});
+ closeEditor();
+ if (window.showToast) window.showToast('Lyrics saved.', 'success');
+ })
+ .catch(() => { saveBtn.disabled = false; if (window.showToast) window.showToast('Could not save lyrics.', 'error'); });
+ });
+ }
+
+ // Lyrics for the server-rendered song, embedded inline (keyed by track id).
+ window._LYRICS = @json($inlineLyrics, JSON_UNESCAPED_UNICODE);
+ window._lyricsShow(window._ytpTrackId || 0);
+})();
+
+/* ── Scroll-based mini player for the music view ────────────────────────
+ Mirrors the IntersectionObserver in video-player.blade.php. Activates the
+ global #ytpMini once playback has started AND the player wrap leaves the
+ viewport; deactivates when it scrolls back in.
+
+ Deferred to DOMContentLoaded because window._miniPlayer is defined in the
+ layout's footer scripts, which parse after this partial. */
+function _setupAudioMiniObserver() {
+ if (!window.IntersectionObserver || !window._miniPlayer) return;
+ var wrapEl = document.getElementById('ytpWrap');
+ var aEl = document.getElementById('audioEl');
+ if (!wrapEl || !aEl) return;
+
+ /* Floating mini player is desktop-only — on mobile the fixed bottom-nav
+ and the locked scroll model make a floating overlay disruptive. */
+ if (window.innerWidth <= 768) return;
+
+ var _scrollRoot = null; /* desktop: window scrolls */
+ var _scrollMiniOn = false;
+ var _hasPlayed = !aEl.paused;
+ aEl.addEventListener('play', function () { _hasPlayed = true; });
+
+ new IntersectionObserver(function (entries) {
+ var e0 = entries[0];
+ var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
+ if (!e0.isIntersecting && !_scrollMiniOn && _hasPlayed && miniAllowed && !window._miniPlayer.isNavMode()) {
+ _scrollMiniOn = true;
+ window._miniPlayer.activateScroll(
+ document.title.replace(/\s*\|.*$/, '').trim(),
+ window.location.href
+ );
+ } else if (e0.isIntersecting && _scrollMiniOn) {
+ _scrollMiniOn = false;
+ window._miniPlayer.deactivateScroll();
+ }
+ }, { root: _scrollRoot, threshold: 0.15 }).observe(wrapEl);
+
+ /* User clicked the X on the mini while still on this page — reset our
+ local flag so a subsequent scroll-away re-activates the mini. */
+ window.addEventListener('miniplayer:scroll-closed', function () {
+ _scrollMiniOn = false;
+ });
+}
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', _setupAudioMiniObserver);
+} else {
+ _setupAudioMiniObserver();
+}
+
})();
diff --git a/resources/views/videos/types/generic.blade.php b/resources/views/videos/types/generic.blade.php
index f52b60d..4e15ff5 100644
--- a/resources/views/videos/types/generic.blade.php
+++ b/resources/views/videos/types/generic.blade.php
@@ -1,20 +1,25 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
-@section('title', $video->title . ' | ' . config('app.name'))
+@php
+ $metaTitle = $shareTitle ?? $video->title;
+ $metaDesc = $shareDescription ?? $video->description;
+ $metaUrl = $video->share_url;
+@endphp
+@section('title', $metaTitle . ' | ' . config('app.name'))
@push('head')
-
-
+
+
-
+
-
-
+
+
@endpush
@@ -226,6 +231,52 @@
color: var(--text-secondary);
}
+ .video-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .action-btn,
+ .comments-section .action-btn {
+ border: none;
+ border-radius: 8px;
+ padding: 8px 14px;
+ font-size: 0.82rem;
+ font-weight: 500;
+ cursor: pointer;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.2s ease;
+ }
+
+ .action-btn:hover,
+ .comments-section .action-btn:hover {
+ background: var(--border-color);
+ transform: translateY(-1px);
+ }
+
+ .action-btn:active,
+ .comments-section .action-btn:active {
+ transform: translateY(0);
+ }
+
+ .action-btn svg,
+ .action-btn i,
+ .comments-section .action-btn svg,
+ .comments-section .action-btn i {
+ flex-shrink: 0;
+ }
+
+ .action-btn.comment-btn {
+ background: var(--brand-red);
+ color: white;
+ border-color: var(--brand-red);
+ }
/* Description */
@@ -374,7 +425,15 @@
margin: 12px 0 6px !important;
}
+ .channel-row {
+ flex-direction: column;
+ align-items: flex-start !important;
+ gap: 12px;
+ }
+ .channel-info {
+ width: 100%;
+ }
.subscribe-btn {
width: 100%;
@@ -418,7 +477,7 @@
-
+ {{-- Generic video: always a single video track (mp4/HLS), GPU-encoded server-side --}}
+ @php $titleLangFlag = \App\Data\Languages::flag($video->language); @endphp
-
- {{ $video->title }}
+
+
+ {{ $video->title }}
@@ -440,11 +501,13 @@
+
@include('videos.partials.description-box', ['video' => $video])
+
@@ -493,22 +556,24 @@
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
- var plAutoplay = plGet('autoplay','1');
- var plLoop = plGet('loop','off');
- var plShuffle = plGet('shuffle','0');
- var plTransiting = false;
+ var plAutoplay = plGet('autoplay','1');
+ var plLoop = plGet('loop','off');
+ var plShuffle = plGet('shuffle','0');
+ var plTransiting = false;
+ // ── shuffle helpers ──────────────────────────────────────
function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; }
function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; }
+ // ── compute adjacent URLs from current state ──────────────
function plAdj(curId) {
- var idx=PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
- if(plShuffle==='1'){
+ var idx = PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
+ if (plShuffle==='1') {
var ord=plGetOrder()||plShuffleOrder();
var pos=ord.indexOf(idx);
- var pp=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
- var np=pos=0?PL_VIDEOS[ord[pp]].url:'', next:np>=0?PL_VIDEOS[ord[np]].url:'' };
+ var prevPos=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
+ var nextPos=pos=0?PL_VIDEOS[ord[prevPos]].url:'', next: nextPos>=0?PL_VIDEOS[ord[nextPos]].url:'' };
}
return {
prev: idx>0?PL_VIDEOS[idx-1].url:'',
@@ -516,27 +581,40 @@
};
}
+ // ── time formatter ───────────────────────────────────────
function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
+ // ── SPA transition — load new video source + update UI ────
async function plTransitionTo(url, pushHist) {
- if(!url||plTransiting) return;
- plTransiting=true;
- if(window._spaScrollToVideo) window._spaScrollToVideo();
+ if (!url || plTransiting) return;
+ plTransiting = true;
+ if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
- var m=url.match(/\/videos\/([^/?#]+)/);
- if(!m){ window.location.href=url; return; }
- var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
+ var m = url.match(/\/videos\/([^/?#]+)/);
+ if (!m) { window.location.href=url; return; }
+ var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp;
- try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
- catch(e){ window.location.href=url; return; }
- if(!resp.ok){ window.location.href=url; return; }
+ try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
+ catch(e) { window.location.href=url; return; }
+ if (!resp.ok) { window.location.href=url; return; }
var d;
- try { d=await resp.json(); }
- catch(e){ window.location.href=url; return; }
+ try { d = await resp.json(); }
+ catch(e) { window.location.href=url; return; }
- // reload video source (HLS or MP4)
- try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
- catch(e){ console.warn('_ytpLoadSource', e); }
+ // Different player type? Fall back to hard nav so the
+ // correct type view (music/match) handles playback.
+ if (d.type && d.type !== 'generic') { window.location.href = url; return; }
+
+ // preserve volume/muted across source swap
+ var vid = document.getElementById('videoPlayer');
+ var _savedVol = vid ? vid.volume : null;
+ var _savedMuted = vid ? vid.muted : null;
+
+ // swap video source (HLS preferred, MP4 fallback)
+ try { if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
+ catch(e) { console.warn('_ytpLoadSource', e); }
+
+ if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
// reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@@ -547,40 +625,47 @@
if(dr&&d.duration) dr.textContent=plFmt(d.duration);
// update page title
- var ts=document.querySelector('.video-title span');
- if(ts) ts.textContent=d.title;
+ var ts=document.getElementById('videoTitleText');
+ if(ts){ ts.textContent=d.title; ts.dataset.primaryTitle=d.title; }
document.title=d.title+' | {{ config("app.name") }}';
// update loop state on video element
- var vid=document.getElementById('videoPlayer');
if(vid) vid.loop=(plLoop==='one');
- PL_CURRENT=d.id;
- try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
+ // update state
+ PL_CURRENT = d.id;
+ try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
+
+ // history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
+ // async: swap description + comments from fetched page
plSwapContent(url);
- } catch(e){
+ } catch(e) {
console.warn('plTransitionTo',e);
} finally {
plTransiting=false;
}
}
+ // ── background page swap: description + comments ──────────
async function plSwapContent(url) {
try {
- var resp=await fetch(url,{headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
- var html=await resp.text();
- var doc=new DOMParser().parseFromString(html,'text/html');
+ var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
+ var html = await resp.text();
+ var doc = new DOMParser().parseFromString(html,'text/html');
- var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
- if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
+ // swap description box
+ var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
+ if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
- var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
+ // swap channel row
+ var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
- var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
- if(ny&&oy){
+ // swap comments (HTML + re-run its init script)
+ var ny=doc.getElementById('ytcSection'), oy=document.getElementById('ytcSection');
+ if(ny&&oy) {
oy.innerHTML=ny.innerHTML;
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
if(ytcScript){
@@ -593,13 +678,27 @@
} catch(e){ console.warn('plSwapContent',e); }
}
- function plHighlight(activeId, scroll){
+ // ── sidebar highlight ────────────────────────────────────
+ function plHighlight(activeId, scroll) {
document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){
- c.classList.toggle('current-video',parseInt(c.dataset.plId)===activeId);
+ c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
});
- if(scroll){ var a=document.querySelector('.sidebar-video-card.current-video'); if(a) a.scrollIntoView({behavior:'smooth',block:'nearest'}); }
+ if(scroll) {
+ // Scroll only inside the sidebar list container — never the page —
+ // so focus stays on the player when the track changes.
+ var active = document.querySelector('.sidebar-video-card.current-video');
+ if (!active) return;
+ var list = active.closest('.recommended-videos-list') || active.parentElement;
+ if (list) {
+ var lr = list.getBoundingClientRect();
+ var ar = active.getBoundingClientRect();
+ var delta = (ar.top + ar.height/2) - (lr.top + lr.height/2);
+ list.scrollTo({ top: list.scrollTop + delta, behavior: 'smooth' });
+ }
+ }
}
+ // ── render control button states ─────────────────────────
function plRender(){
var adj=plAdj(PL_CURRENT);
var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn');
@@ -614,14 +713,15 @@
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
if(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev;
+ // In-player prev/next buttons
+ document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
+ document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
}
- window.plGoTo = function(url){ if(url) plTransitionTo(url); };
- window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
- window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
- window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
- window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
- window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
+ // ── public API ────────────────────────────────────────────
+ window.plGoTo = function(url){ if(url) plTransitionTo(url); };
+ window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
+ window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
// hook into video player: intercept next/prev and ended
window._ytpNavOverride = {
@@ -630,8 +730,14 @@
};
window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
- window.addEventListener('popstate',function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url,false); });
+ window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
+ window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
+ window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
+ // ── browser back/forward ──────────────────────────────────
+ window.addEventListener('popstate', function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url, false); });
+
+ // ── init ──────────────────────────────────────────────────
function plInit(){
var v=document.getElementById('videoPlayer');
if(v&&plLoop==='one') v.loop=true;
@@ -694,26 +800,164 @@
@endif
@else
- {{-- Up Next header + autoplay toggle --}}
-
-
- Up Next
-
-
-
-
- Autoplay
-
-
+
+
Up Next
+
+
+ Autoplay
+
+
+
@if ($recommendedVideos && $recommendedVideos->count() > 0)
@foreach ($recommendedVideos as $recVideo)
diff --git a/resources/views/videos/types/match.blade.php b/resources/views/videos/types/match.blade.php
index 877772b..45b1f79 100644
--- a/resources/views/videos/types/match.blade.php
+++ b/resources/views/videos/types/match.blade.php
@@ -2612,6 +2612,9 @@
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
+ // Different player type? Hard-nav so the correct view loads.
+ if (d.type && d.type !== 'match') { window.location.href = url; return; }
+
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
@@ -2873,6 +2876,9 @@
if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json();
+ // Different player type? Hard-nav so the correct view loads.
+ if (d.type && d.type !== 'match') { window.location.href = url; return; }
+
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
diff --git a/resources/views/videos/types/music.blade.php b/resources/views/videos/types/music.blade.php
index 4fcb158..57845f3 100644
--- a/resources/views/videos/types/music.blade.php
+++ b/resources/views/videos/types/music.blade.php
@@ -572,6 +572,9 @@
try { d = await resp.json(); }
catch(e) { window.location.href=url; return; }
+ // Different player type? Hard-nav so the correct view (generic/match) loads.
+ if (d.type && d.type !== 'music') { window.location.href = url; return; }
+
// swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl');
if (audio) {
@@ -631,6 +634,9 @@
document.body.removeChild(ns);
}
}
+ // The swapped content re-renders video-actions (incl. the
+ // owner lyrics button) fresh+hidden — re-apply its state.
+ if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
} catch(e){ console.warn('plSwapContent',e); }
}
@@ -796,6 +802,9 @@
if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json();
+ // Different player type? Hard-nav so the correct view loads.
+ if (d.type && d.type !== 'music') { window.location.href = url; return; }
+
var audio = document.getElementById('audioEl');
if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted;
@@ -850,6 +859,8 @@
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
});
}
+ // Re-apply the owner lyrics button state after the swap re-renders it.
+ if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
} catch(e) { console.warn('recSwapContent', e); }
}
diff --git a/routes/web.php b/routes/web.php
index f9b12d3..7c1b548 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -49,6 +49,10 @@ Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slid
Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
+Route::post('/videos/{video}/lyrics/generate', [VideoController::class, 'generateLyrics'])->name('videos.lyrics.generate')->middleware(['auth', 'throttle:20,1']);
+Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsProgress'])->name('videos.lyrics.progress');
+Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
+Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
@@ -161,6 +165,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Playlist video management
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
+ // Body-based remove (mirror of addVideo) — used by the add-to-playlist modal
+ // where the front-end only has the numeric video id, not the encoded route key.
+ Route::delete('/playlists/{playlist}/videos', [PlaylistController::class, 'removeVideoByBody'])->name('playlists.removeVideoByBody');
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
// Playlist actions
@@ -227,6 +234,16 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
Route::get('/settings', [SuperAdminController::class, 'settings'])->name('settings');
Route::post('/settings', [SuperAdminController::class, 'updateSettings'])->name('settings.update');
Route::get('/settings/detect-gpu', [SuperAdminController::class, 'detectGpu'])->name('settings.detect-gpu');
+ Route::post('/settings/llm-test', [SuperAdminController::class, 'llmProviderTest'])->name('settings.llm-test');
+
+ // Lyrics Pipeline toggles (own page)
+ Route::get('/lyrics', [SuperAdminController::class, 'lyrics'])->name('lyrics');
+
+ // GPU Accelerator (own page)
+ Route::get('/gpu', [SuperAdminController::class, 'gpu'])->name('gpu');
+
+ // Backup & Restore (own page)
+ Route::get('/backup', [SuperAdminController::class, 'backup'])->name('backup');
// NAS Storage
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');
diff --git a/screenshots/Screenshot 2026-05-01 104419.png b/screenshots/Screenshot 2026-05-01 104419.png
deleted file mode 100644
index 82da664..0000000
Binary files a/screenshots/Screenshot 2026-05-01 104419.png and /dev/null differ
diff --git a/screenshots/Screenshot_2026-05-04-00-00-03-73_6012fa4d4ddec268fc5c7112cbb265e7.jpg b/screenshots/Screenshot_2026-05-04-00-00-03-73_6012fa4d4ddec268fc5c7112cbb265e7.jpg
deleted file mode 100644
index 14b0845..0000000
Binary files a/screenshots/Screenshot_2026-05-04-00-00-03-73_6012fa4d4ddec268fc5c7112cbb265e7.jpg and /dev/null differ
diff --git a/scripts/fix-gpu-host.sh b/scripts/fix-gpu-host.sh
deleted file mode 100755
index 06b7e05..0000000
--- a/scripts/fix-gpu-host.sh
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/bin/bash
-# ============================================================
-# fix-gpu-host.sh — Run this on the PROXMOX HOST
-# Fixes NVIDIA driver version mismatch so GPU encoding works
-# ============================================================
-set -euo pipefail
-
-RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
-
-log() { echo -e "${GREEN}[✓]${NC} $1"; }
-warn() { echo -e "${YELLOW}[!]${NC} $1"; }
-fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
-
-echo ""
-echo "======================================================"
-echo " NVIDIA Driver Fix — Proxmox Host"
-echo "======================================================"
-echo ""
-
-# Must be root
-[[ $EUID -ne 0 ]] && fail "Please run as root: sudo bash fix-gpu-host.sh"
-
-TARGET="580.65.06-1"
-CURRENT=$(modinfo nvidia 2>/dev/null | awk '/^version:/{print $2}' || echo "none")
-
-log "Current kernel module version: ${CURRENT}"
-log "Target version: 580.65.06"
-echo ""
-
-if [[ "$CURRENT" == "580.65.06" ]]; then
- log "Already at correct version. Nothing to do."
- echo ""
- echo "Now run fix-gpu-vm.sh inside the VM."
- exit 0
-fi
-
-warn "The host driver (${CURRENT}) does not match the VM userspace (580.126.09)."
-warn "This script will install nvidia-open-580=580.65.06 and reboot the host."
-echo ""
-read -p "Continue? (yes/no): " CONFIRM
-[[ "$CONFIRM" != "yes" ]] && { warn "Aborted."; exit 0; }
-
-echo ""
-log "Updating package lists..."
-apt-get update -qq
-
-log "Installing nvidia-open-580=580.65.06-1 ..."
-apt-get install -y "nvidia-open-580=${TARGET}" || \
- fail "Installation failed. Check that the CUDA local repo is still configured."
-
-log "Pinning package to prevent accidental upgrades..."
-apt-mark hold nvidia-open-580
-
-echo ""
-log "Done! The host will reboot in 10 seconds."
-warn "After reboot, SSH back in and run fix-gpu-vm.sh inside the VM."
-echo ""
-sleep 10
-reboot
diff --git a/scripts/fix-gpu-vm.sh b/scripts/fix-gpu-vm.sh
deleted file mode 100755
index 4e7b946..0000000
--- a/scripts/fix-gpu-vm.sh
+++ /dev/null
@@ -1,132 +0,0 @@
-#!/bin/bash
-# ============================================================
-# fix-gpu-vm.sh — Run this INSIDE the VM (your app server)
-# Run AFTER fix-gpu-host.sh has completed and host has rebooted
-# ============================================================
-set -euo pipefail
-
-RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
-
-log() { echo -e "${GREEN}[✓]${NC} $1"; }
-warn() { echo -e "${YELLOW}[!]${NC} $1"; }
-fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
-info() { echo -e "${BLUE}[i]${NC} $1"; }
-
-echo ""
-echo "======================================================"
-echo " NVIDIA Driver Fix — VM Userspace"
-echo "======================================================"
-echo ""
-
-[[ $EUID -ne 0 ]] && fail "Please run as root: sudo bash fix-gpu-vm.sh"
-
-FFMPEG="/usr/lib/jellyfin-ffmpeg/ffmpeg"
-TARGET="580.65.06"
-APP_DIR="/var/www/videoplatform"
-
-# ── 1. Check Proxmox host kernel module ──────────────────────────────────────
-HOST_VER=$(cat /proc/driver/nvidia/version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
-info "Proxmox host kernel module: ${HOST_VER}"
-
-if [[ "$HOST_VER" != "580.65.06" ]]; then
- warn "Host kernel module is ${HOST_VER}, not 580.65.06."
- warn "Please run fix-gpu-host.sh on the Proxmox host first, then reboot the host."
- read -p "Continue anyway? (yes/no): " FORCE
- [[ "$FORCE" != "yes" ]] && { warn "Aborted."; exit 0; }
-fi
-
-# ── 2. Check jellyfin-ffmpeg is installed ────────────────────────────────────
-[[ -x "$FFMPEG" ]] || fail "jellyfin-ffmpeg not found at ${FFMPEG}. Install it first: dpkg -i /tmp/jellyfin-ffmpeg7.deb"
-log "jellyfin-ffmpeg found: $($FFMPEG -version 2>&1 | head -1)"
-
-# ── 3. Downgrade NVIDIA userspace packages ───────────────────────────────────
-CURRENT_COMPUTE=$(dpkg -l libnvidia-compute-580 2>/dev/null | awk '/^ii/{print $3}' | cut -d- -f1 || echo "none")
-info "Current libnvidia-compute-580 version: ${CURRENT_COMPUTE}"
-
-if [[ "$CURRENT_COMPUTE" == "$TARGET" ]]; then
- log "NVIDIA userspace already at ${TARGET}. Skipping downgrade."
-else
- warn "Downgrading NVIDIA userspace from ${CURRENT_COMPUTE} → ${TARGET}..."
- echo ""
-
- UBUNTU_VER="${TARGET}-0ubuntu0.22.04.1"
-
- apt-get install -y --allow-downgrades \
- "libnvidia-compute-580=${UBUNTU_VER}" \
- "libnvidia-encode-580=${UBUNTU_VER}" \
- "libnvidia-decode-580=${UBUNTU_VER}" \
- "nvidia-utils-580=${UBUNTU_VER}" || \
- fail "Package downgrade failed. Try: apt-get update && then re-run this script."
-
- log "Packages downgraded successfully."
-
- # Pin to prevent apt from auto-upgrading and breaking things again
- for pkg in libnvidia-compute-580 libnvidia-encode-580 libnvidia-decode-580 nvidia-utils-580; do
- apt-mark hold "$pkg"
- done
- log "Packages pinned at ${TARGET} to prevent accidental upgrades."
-fi
-
-# ── 4. Test NVENC ─────────────────────────────────────────────────────────────
-echo ""
-info "Testing NVENC encoding with new setup..."
-TMP=$(mktemp /tmp/nvenc_test_XXXX.mp4)
-
-"$FFMPEG" -f lavfi -i color=c=black:s=128x72:r=25 -frames:v 1 \
- -c:v h264_nvenc -y "$TMP" > /tmp/nvenc_test.log 2>&1
-EXIT=$?
-
-if [[ $EXIT -eq 0 && -s "$TMP" ]]; then
- log "NVENC test PASSED! GPU encoding is working."
- rm -f "$TMP"
- NVENC_OK=true
-else
- warn "NVENC test failed (exit ${EXIT}). Falling back to CPU encoding."
- cat /tmp/nvenc_test.log | tail -5
- rm -f "$TMP"
- NVENC_OK=false
-fi
-
-# ── 5. Update app settings ────────────────────────────────────────────────────
-echo ""
-info "Updating application settings..."
-
-if [[ -d "$APP_DIR" ]]; then
- # Set ffmpeg binary path
- php "$APP_DIR/artisan" tinker --execute="
- App\Models\Setting::set('ffmpeg_binary', '$FFMPEG');
- echo 'ffmpeg_binary set to: ' . App\Models\Setting::ffmpegBinary();
- " 2>/dev/null && log "App FFmpeg binary path set to: ${FFMPEG}"
-
- if [[ "$NVENC_OK" == "true" ]]; then
- php "$APP_DIR/artisan" tinker --execute="
- App\Models\Setting::set('gpu_enabled', 'true');
- echo 'gpu_enabled: ' . App\Models\Setting::get('gpu_enabled');
- " 2>/dev/null && log "GPU encoding enabled in app settings."
- else
- php "$APP_DIR/artisan" tinker --execute="
- App\Models\Setting::set('gpu_enabled', 'false');
- echo 'gpu_enabled set to false (CPU fallback active)';
- " 2>/dev/null && warn "GPU disabled in app settings — CPU fallback active."
- fi
-else
- warn "App directory not found at ${APP_DIR}, skipping settings update."
-fi
-
-# ── 6. Summary ────────────────────────────────────────────────────────────────
-echo ""
-echo "======================================================"
-if [[ "$NVENC_OK" == "true" ]]; then
- echo -e "${GREEN} ALL DONE — GPU encoding is active!${NC}"
- echo ""
- echo " FFmpeg binary : ${FFMPEG}"
- echo " GPU encoder : h264_nvenc (NVIDIA RTX 3060)"
- echo " Driver match : kernel=${HOST_VER} / userspace=${TARGET}"
-else
- echo -e "${YELLOW} DONE — Running with CPU fallback (libx264)${NC}"
- echo ""
- echo " Videos will still encode correctly, just slightly slower."
- echo " Check /tmp/nvenc_test.log for NVENC error details."
-fi
-echo "======================================================"
-echo ""
diff --git a/storage/app/.gitignore b/storage/app/.gitignore
deleted file mode 100755
index 8f4803c..0000000
--- a/storage/app/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-*
-!public/
-!.gitignore
diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/app/public/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore
deleted file mode 100755
index 05c4471..0000000
--- a/storage/framework/.gitignore
+++ /dev/null
@@ -1,9 +0,0 @@
-compiled.php
-config.php
-down
-events.scanned.php
-maintenance.php
-routes.php
-routes.scanned.php
-schedule-*
-services.json
diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore
deleted file mode 100755
index 01e4a6c..0000000
--- a/storage/framework/cache/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-*
-!data/
-!.gitignore
diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/framework/cache/data/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/framework/sessions/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/framework/testing/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/framework/views/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore
deleted file mode 100755
index d6b7ef3..0000000
--- a/storage/logs/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore