Compare commits
102
Commits
73527f3781
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab59fc1bb | ||
|
|
e76ac39f99 | ||
|
|
c59b36ebf7 | ||
|
|
8fef257c86 | ||
|
|
5bf1128196 | ||
|
|
639279f2a2 | ||
|
|
8c6746e547 | ||
|
|
45505e6419 | ||
|
|
59513f2051 | ||
|
|
677e8711dd | ||
|
|
f7c3b236ab | ||
|
|
e550d8d15b | ||
|
|
7ba272edf6 | ||
|
|
4d9bc0f501 | ||
|
|
73de5bc97e | ||
|
|
341e94a4f8 | ||
|
|
574c9581bb | ||
|
|
22d6b1507d | ||
|
|
5aef41201a | ||
|
|
5adafc7616 | ||
|
|
05ccb45fe4 | ||
|
|
b7f3f6989b | ||
|
|
0e8bcfd47b | ||
|
|
8e719be105 | ||
|
|
7dd92aaeb2 | ||
|
|
1b2377f20f | ||
|
|
e939dd3a53 | ||
|
|
d5d35ab85e | ||
|
|
8812aaf336 | ||
|
|
dd6091d0e4 | ||
|
|
c3955c4a7c | ||
|
|
9e613c05d2 | ||
|
|
646d3e9d2a | ||
|
|
36295fcd6a | ||
|
|
9eb9535c83 | ||
|
|
4581870517 | ||
|
|
36061d1561 | ||
|
|
d1fd76c278 | ||
|
|
a2752643e9 | ||
|
|
45b5f586ba | ||
|
|
6b2a67c4cd | ||
|
|
4b21886cb9 | ||
|
|
133ab6e623 | ||
|
|
a25af30700 | ||
|
|
d9f97a672e | ||
|
|
fa4e09ad62 | ||
|
|
8aa08e705a | ||
|
|
8fdac4b483 | ||
|
|
73620a0f24 | ||
|
|
4718888de0 | ||
|
|
c7d2f25ef6 | ||
|
|
48c947d171 | ||
|
|
c9123bc7b8 | ||
|
|
3122420a3d | ||
|
|
76584651a6 | ||
|
|
1cefc6ad2d | ||
|
|
a1493462f7 | ||
|
|
56b6a83b34 | ||
|
|
5058eaff2f | ||
|
|
468820c9fe | ||
|
|
2d33e39b9b | ||
|
|
20c8fdcdf3 | ||
|
|
9f82d3cf31 | ||
|
|
3e18bd2150 | ||
|
|
665bad76bf | ||
|
|
04d63cccdb | ||
|
|
5b6bd86d70 | ||
|
|
b05ff14b3b | ||
|
|
5d038488fb | ||
|
|
2b5e480c9a | ||
|
|
19741f489b | ||
|
|
ed921a4ccf | ||
|
|
1c4e4986b5 | ||
|
|
55247828e1 | ||
|
|
cba8e01d56 | ||
|
|
716693a74f | ||
|
|
057ce87564 | ||
|
|
402c6dc89f | ||
|
|
ad6cbaf742 | ||
|
|
76f5ef0c84 | ||
|
|
e0aa6e4817 | ||
|
|
3d8d7b8efe | ||
|
|
da714958c6 | ||
|
|
847020fd02 | ||
|
|
fcb752dd76 | ||
|
|
8d27520bdb | ||
|
|
ba8ab11ce4 | ||
|
|
af276f2cd1 | ||
|
|
8ea63a6720 | ||
|
|
5d4d21dcec | ||
|
|
0a0851e120 | ||
|
|
eb5cea8630 | ||
|
|
0f3110f59d | ||
|
|
4cd1d195a7 | ||
|
|
f8cefc1bf9 | ||
|
|
cffd8491d0 | ||
|
|
ea95a60d79 | ||
|
|
ecb89f9514 | ||
|
|
18fb4fbd63 | ||
|
|
6b43a7060a | ||
|
|
80948efff7 | ||
|
|
f98e5415a3 |
No files matched your search
@@ -144,6 +144,9 @@ Stored value: ISO2 code (e.g. `"BH"`).
|
||||
|---|---|---|
|
||||
| `resources/views/user/profile.blade.php` | `nationality` | Edit Profile form |
|
||||
| `resources/views/auth/register.blade.php` | `nationality` | Registration form — mandatory |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[p1_country]` | Fighter 1 country → header flag |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[p2_country]` | Fighter 2 country → header flag |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[referee_country]` | Referee country → header flag |
|
||||
|
||||
---
|
||||
|
||||
@@ -271,6 +274,19 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
|
||||
|
||||
---
|
||||
|
||||
## `<x-save-to-playlist-button>`
|
||||
|
||||
**File:** `resources/views/components/save-to-playlist-button.blade.php`.
|
||||
**Rule:** Canonical "Save to playlist" trigger. Opens the singleton `layouts/partials/add-to-playlist-modal.blade.php` (already included once via `layouts/app.blade.php`) by calling `openAddToPlaylistModal($video->id)`. **Always pass `$video->id`** — the backend (`PlaylistController::addVideo` / `removeVideoByBody`) validates `video_id` against `exists:videos,id`. Never pass `$video->getRouteKey()`. Props: `video` (required), `tag` (`button`|`a`); extra attributes forwarded; slot overrides the default label.
|
||||
|
||||
| View file | Usage | Notes |
|
||||
|---|---|---|
|
||||
| `resources/views/components/video-card.blade.php` | `<x-save-to-playlist-button :video tag="a" class="dropdown-item">` | Card 3-dot menu item |
|
||||
| `resources/views/components/video-actions.blade.php` | `<x-save-to-playlist-button :video class="action-btn desktop-action">` + dropdown-item | Watch-page actions (desktop + dropdown) |
|
||||
| `resources/views/videos/show.blade.php` | `<x-save-to-playlist-button :video class="yt-action-btn">` (×2) | Watch page mobile + desktop save buttons |
|
||||
|
||||
---
|
||||
|
||||
## Modification checklist
|
||||
|
||||
When you modify any of these components, work through this list:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
APP_ENV=testing
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_KEY=base64:Zm7kR6vDfsQQmCeWTR2Mk2WQGu6fGP1UHEcZVk/hIt0=
|
||||
|
||||
# ── Hard rule — tests must never touch the shared SQLite file. ───────────
|
||||
# phpunit.xml also sets these via <env>, but having them here means Laravel
|
||||
# picks them up during the earliest bootstrap step (before any trait or
|
||||
# setUp hook can run), closing the window that let migrate:fresh wipe the
|
||||
# dev DB on 2026-10-21. If you change these values, read tests/TestCase.php's
|
||||
# assertSafeTestDatabase() guard first.
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=:memory:
|
||||
|
||||
BCRYPT_ROUNDS=4
|
||||
CACHE_DRIVER=array
|
||||
MAIL_MAILER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=array
|
||||
|
||||
TELESCOPE_ENABLED=false
|
||||
PULSE_ENABLED=false
|
||||
+10
-1
@@ -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
|
||||
@@ -122,6 +122,49 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
|
||||
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
|
||||
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.
|
||||
|
||||
**Every new Blade page must ship desktop + mobile style partials from day one.** When you create a view at `resources/views/<scope>/<page>.blade.php`, also create:
|
||||
|
||||
```
|
||||
resources/views/<scope>/partials/<page>/styles/
|
||||
├── desktop.blade.php ← base + desktop CSS (the foundation)
|
||||
└── mobile.blade.php ← @media (max-width: 768px) and below
|
||||
```
|
||||
|
||||
…and wire them up in the page's `@section('extra_styles')` block:
|
||||
|
||||
```blade
|
||||
@section('extra_styles')
|
||||
@php
|
||||
// Any shared Blade variables consumed by BOTH partials (palette,
|
||||
// computed sizes, theme values) must be defined here, not inside
|
||||
// a partial — each @include runs in its own variable scope.
|
||||
@endphp
|
||||
@include('<scope>.partials.<page>.styles.desktop')
|
||||
@include('<scope>.partials.<page>.styles.mobile')
|
||||
@endsection
|
||||
```
|
||||
|
||||
Rules that must never be violated:
|
||||
|
||||
1. **Never put a `@media (max-width: ...)` block inside `desktop.blade.php`.** All mobile-scoped rules go in `mobile.blade.php`. The whole point is that editing one cannot affect the other.
|
||||
2. **Never put a non-media-query rule inside `mobile.blade.php`.** Every selector in the mobile partial must be inside an `@media (max-width: 768px)` (or smaller) block. A naked rule would leak to desktop.
|
||||
3. **Folder name is `styles/`, not `styles.`.** Laravel resolves dots in `@include('foo.bar.baz')` as directory separators, so a file called `styles.mobile.blade.php` can't be referenced by `@include('....styles.mobile')`. Always put the two files under a `styles/` subdirectory.
|
||||
4. **Shared `@php` variables go in the parent page**, never duplicated across partials. Define `$hue`, palette values, computed sizes, etc. in the page's `@section('extra_styles')` block above the `@include`s, so both partials inherit them.
|
||||
5. **Reference example:** the channel page (`resources/views/user/channel.blade.php` + `resources/views/user/partials/channel/styles/{desktop,mobile}.blade.php`) is the canonical implementation. Mirror its structure on every new page.
|
||||
6. **No “I'll add mobile styles later.”** A page without a mobile partial is not finished. Create `mobile.blade.php` with at minimum the empty media-query scaffold:
|
||||
```blade
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
/* mobile overrides for <page> */
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
/* small-phone refinements */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
…even when there are no mobile-specific rules yet. This guarantees the file exists for the next person who needs to tweak mobile.
|
||||
7. **When editing an existing page that still has inline styles**, refactor it to this structure as part of the same task — don't add new CSS to a page that hasn't been split yet without splitting it first.
|
||||
|
||||
**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**
|
||||
|
||||
**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** — this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.
|
||||
@@ -190,71 +233,144 @@ Rules that must never be violated:
|
||||
|
||||
**NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.**
|
||||
|
||||
#### Framework storage lives at `data/`, not `storage/`
|
||||
|
||||
**The Laravel framework storage directory has been relocated from `storage/` to `data/` so the only entry named `storage` in the project tree is the `public/storage` symlink.**
|
||||
|
||||
Layout (verbatim):
|
||||
- `data/app/` → file storage (with `app/public/` exposed to the web).
|
||||
- `data/framework/` → sessions, cache, compiled views, route cache.
|
||||
- `data/logs/` → application logs.
|
||||
- `public/storage` → a **symlink** to `../data/app/public`. It exposes public files to the web without making the rest of `data/` reachable.
|
||||
|
||||
The redirect is wired in `bootstrap/app.php`:
|
||||
```php
|
||||
$app->useStoragePath(base_path('data'));
|
||||
```
|
||||
|
||||
This means every `storage_path(...)` call, `Storage::disk('local'|'public')` operation, session/cache/view/log write, and the local NAS file cache resolves through `data/`. The `storage/` directory at the project root **does not exist** and must never be re-created.
|
||||
|
||||
Rules:
|
||||
1. Never re-create `storage/` at the project root. Laravel's storage path is `data/`. The framework can't run without `data/`.
|
||||
2. Never delete the `data/` directory or any subdirectory of it.
|
||||
3. Never replace the `public/storage` symlink with a real directory or copy files into it. It must remain a symlink targeting `../data/app/public`.
|
||||
4. Never move user files into `public/storage` directly. All file writes go through the `data/app/` tree (NAS-mirrored paths), and the symlink + `MediaController` handle public serving.
|
||||
5. If `public/storage` is missing or broken, fix it with `ln -sfn ../data/app/public public/storage`. Do not run `php artisan storage:link` blindly — that targets `data/app/public` which doesn't exist; you'd have to pass `--relative` and a custom target.
|
||||
6. Nginx must alias `/storage` to `/var/www/videoplatform/data/app/public` (set in `/etc/nginx/sites-enabled/videoplatform`). If you ever edit that file, keep the alias pointing at `data/`, not `storage/`.
|
||||
|
||||
#### Canonical storage layout — IDENTICAL on local disk and on the NAS
|
||||
|
||||
**This is the single source-of-truth file structure. Both the local `storage/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**
|
||||
**This is the single source-of-truth file structure. Both the local `data/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**
|
||||
|
||||
**Type-segregated top-level folders.** Every video has a `type` (`music`, `match`, `generic`). The folder it lives under is determined by that type and is frozen at upload time — editing a video's type does NOT move its files. The mapping is:
|
||||
|
||||
| Video `type` | Folder | Has `tracks/` subfolder? |
|
||||
|---|---|---|
|
||||
| `music` | `music/` | **Yes** — every track (primary + extras) lives in its own subfolder |
|
||||
| `match` (sports) | `sports/` | No — single video file in the slug folder |
|
||||
| `generic` | `videos/` | No — single video file in the slug folder |
|
||||
|
||||
```
|
||||
users/{user-slug}/
|
||||
├── profile/
|
||||
│ ├── avatar.{ext}
|
||||
│ └── cover.{ext} ← banner (DB column is users.banner)
|
||||
│ └── cover.{ext} ← banner (DB column is users.banner)
|
||||
├── playlists/
|
||||
│ └── {playlist-id}/
|
||||
│ └── thumb.{ext}
|
||||
├── posts/
|
||||
│ └── {post-id}/
|
||||
│ └── {filename} ← post images
|
||||
└── videos/
|
||||
└── {song-slug}/ ← ONE folder per song/video. EVERYTHING for it lives here.
|
||||
│ ┌──────────────── SOURCE OF TRUTH (synced to NAS) ───────┐
|
||||
├── {title-slug}.{ext} ← primary track / video file (canonical name)
|
||||
├── {song-slug}-{lang}-{id}.{ext} ← each extra-language audio track (one per language)
|
||||
├── slides/
|
||||
│ └── {position}.{ext} ← cover image(s) / slideshow frames
|
||||
├── thumb.{ext} ← cover for video-type uploads that have no slides
|
||||
├── meta.json ← {id, user_id, title, created_at}
|
||||
│ └──────────────────────────────────────────────────────┘
|
||||
└── cache/ ← REGENERABLE renders. LOCAL-ONLY. Never on NAS. Safe to wipe.
|
||||
├── video.mp4 ← generated "Download Video" (plain)
|
||||
├── video-viz.mp4 ← generated "Download Video" (visualizer)
|
||||
└── hls/
|
||||
└── {variant}/… ← adaptive-streaming rendition (.m3u8 + .ts)
|
||||
│ └── {filename} ← post images
|
||||
│
|
||||
├── music/ ← type = music
|
||||
│ └── {song-slug}/ ← ONE folder per song
|
||||
│ ├── meta.json ← {id, user_id, title, type:"music", created_at}
|
||||
│ └── tracks/
|
||||
│ ├── {primary-lang}-{primary-track-id}/ ← primary track has its own folder
|
||||
│ │ │ ┌─── SOURCE OF TRUTH (synced to NAS) ───┐
|
||||
│ │ ├── audio.{ext} ← the audio file (canonical name)
|
||||
│ │ ├── lyrics.ass ← synced lyrics for THIS track
|
||||
│ │ ├── thumb.{ext} ← cover when this track has no slides
|
||||
│ │ ├── slides/
|
||||
│ │ │ └── {position}.{ext} ← THIS track's slideshow frames
|
||||
│ │ │ └────────────────────────────────────────┘
|
||||
│ │ └── cache/ ← LOCAL-only, regenerable, never on NAS
|
||||
│ │ ├── video.mp4
|
||||
│ │ ├── video-viz.mp4
|
||||
│ │ └── hls/{variant}/…
|
||||
│ └── {extra-lang}-{extra-track-id}/ ← every extra-language track, same shape
|
||||
│ ├── audio.{ext}
|
||||
│ ├── lyrics.ass
|
||||
│ ├── thumb.{ext}
|
||||
│ ├── slides/{position}.{ext}
|
||||
│ └── cache/…
|
||||
│
|
||||
├── sports/ ← type = match
|
||||
│ └── {match-slug}/
|
||||
│ │ ┌─── SOURCE OF TRUTH ───┐
|
||||
│ ├── meta.json
|
||||
│ ├── video.{ext} ← the match video
|
||||
│ ├── thumb.{ext}
|
||||
│ │ └────────────────────────┘
|
||||
│ └── cache/ ← LOCAL-only
|
||||
│ └── hls/{variant}/…
|
||||
│
|
||||
└── videos/ ← type = generic
|
||||
└── {video-slug}/
|
||||
│ ┌─── SOURCE OF TRUTH ───┐
|
||||
├── meta.json
|
||||
├── video.{ext}
|
||||
├── thumb.{ext}
|
||||
│ └────────────────────────┘
|
||||
└── cache/ ← LOCAL-only
|
||||
└── hls/{variant}/…
|
||||
```
|
||||
|
||||
**Sources vs. the `cache/` subfolder — a hard rule:**
|
||||
- The song-folder **root** holds only the **source of truth** (primary track, extra-language tracks, slides, thumb, meta.json). These are what gets pushed to / pulled from the NAS.
|
||||
- **`cache/` holds only regenerable, derived renders** — the "Download Video" mp4s and the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (`syncVideo()` pushes only the source files). Deleting `cache/` (or the whole subtree) is always safe — it rebuilds on the next download/stream. There must be **no shared `public/slideshow` or `public/hls` caches** anymore; every render lives under its song's `cache/`.
|
||||
- DB pointers: `videos.slideshow_video_path` → `users/.../{song}/cache/video.mp4`; `videos.hls_path` → `users/.../{song}/cache/hls`.
|
||||
- Reclaim space anytime with `php artisan nas:free-local-storage` (deletes song `cache/` folders); `tracks:reorganize` never treats anything under `cache/` as an orphan.
|
||||
- The track-folder root (or video-folder root for sports/generic) holds only the **source of truth** (audio/video file, slides, thumb, lyrics, meta.json). These are what gets pushed to / pulled from the NAS.
|
||||
- **`cache/` holds only regenerable, derived renders** — "Download Video" mp4s, the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (the sync layer pushes only source files). Deleting `cache/` is always safe; it rebuilds on next download/stream.
|
||||
- DB pointers: `videos.slideshow_video_path` → `users/.../<track-folder>/cache/video.mp4` (music) or `users/.../<video-folder>/cache/video.mp4` (sports/generic). `videos.hls_path` → the parent `cache/hls` for that file.
|
||||
- Reclaim space anytime with `php artisan nas:free-local-storage`; `tracks:reorganize` never treats anything under `cache/` as an orphan.
|
||||
|
||||
**Naming rules (apply on both local and NAS, always lowercase, Unicode-preserving slug via `NasSyncService::titleSlug()`):**
|
||||
- **One folder per song/video** at `users/{slug}/videos/{song-slug}/`. There is **NO `tracks/` subfolder** — every audio track (primary AND secondary) sits directly in this folder.
|
||||
- **Primary** keeps the canonical `{title-slug}.{ext}` (the name the NAS layer reconstructs in `uploadDirectToNas()` / `syncVideo()` — do not change this scheme).
|
||||
- **Each extra-language track** is `{song-slug}-{lang}-{db-track-id}.{ext}`. The DB id makes every track filename globally unique, so no two tracks can ever overwrite each other even within the same folder/language.
|
||||
- Slides are `slides/{position}.{ext}`.
|
||||
|
||||
- **Music — one song folder, one folder per track inside it:**
|
||||
- Song folder: `users/{slug}/music/{song-slug}/`.
|
||||
- Track folder name: `{lang}-{db-track-id}` (e.g. `en-12`, `ar-47`). The DB id makes the folder name globally unique even when two tracks share a language.
|
||||
- Inside each track folder, filenames are **canonical** — `audio.{ext}`, `lyrics.ass`, `thumb.{ext}`, `slides/{position}.{ext}`. **Do not** put track-id or language in these filenames; the *folder* already disambiguates.
|
||||
- **Sports (match):** `users/{slug}/sports/{match-slug}/video.{ext}`, `thumb.{ext}`.
|
||||
- **Generic:** `users/{slug}/videos/{video-slug}/video.{ext}`, `thumb.{ext}`.
|
||||
|
||||
**Type is frozen at upload time.** When a user edits a video's type (e.g. `generic` → `music`), the on-disk folder does NOT move. The path remains under the original type folder for the life of that record. New uploads use the type-aware path. The migration command (`tracks:reorganize`) is the only thing that may move files between type folders.
|
||||
|
||||
File types and their canonical locations (same string on NAS and local):
|
||||
|
||||
| File type | Path (relative to NAS root and to `storage/app/`) | Served via |
|
||||
| File type | Path (relative to NAS root and to `data/app/`) | Served via |
|
||||
|---|---|---|
|
||||
| Primary video / audio | `users/{slug}/videos/{song-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Extra audio track | `users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
|
||||
| Video thumbnail | `users/{slug}/videos/{song-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Slides | `users/{slug}/videos/{song-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Music primary audio | `users/{slug}/music/{song-slug}/tracks/{lang}-{primary-id}/audio.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Music extra audio track | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/audio.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
|
||||
| Music track lyrics | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/lyrics.ass` | `NasSyncService::getLocalLyrics()` |
|
||||
| Music track slides | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Music track thumb | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Sports video | `users/{slug}/sports/{match-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Sports thumb | `users/{slug}/sports/{match-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Generic video | `users/{slug}/videos/{video-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Generic thumb | `users/{slug}/videos/{video-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
|
||||
| Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
|
||||
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
|
||||
|
||||
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs (dry-run by default; `--force` to apply).
|
||||
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs/videos (dry-run by default; `--force` to apply). It moves any pre-existing flat-layout content into the type-segregated, per-track-folder layout above and updates the DB pointers in lockstep.
|
||||
|
||||
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
|
||||
**Slide sharing across tracks (music only):** A music track owns its own slides via `video_slides.audio_track_id`. If a track has no slides of its own, the player and the render pipeline fall back via `Video::slidesForTrack($trackId)` to: (1) the primary track's slides, then (2) any other track's slides, then (3) the cover image. Files are never duplicated to support this — the fallback is purely a query/runtime concern.
|
||||
|
||||
The only files that live permanently on local disk are HLS segments (`data/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
|
||||
|
||||
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
|
||||
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
|
||||
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
|
||||
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
|
||||
- `data/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
|
||||
- `data/app/public/avatars/` — formerly held user avatars; now NAS only
|
||||
- `data/app/public/videos/` — formerly held uploaded video files; now NAS only
|
||||
|
||||
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Backs up the database, and optionally mirrors the media library.
|
||||
*
|
||||
* Three things make this a backup rather than a file copy:
|
||||
*
|
||||
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
|
||||
* checkpoint, so the artifact is a coherent point-in-time copy even while
|
||||
* the app is writing. Copying the file by hand mid-write is not.
|
||||
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
|
||||
* back before it counts as a success. An unverified backup is a rumour.
|
||||
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
|
||||
* not a dead disk, so a configured remote disk receives a copy and the
|
||||
* command warns loudly on every run where none is set.
|
||||
*
|
||||
* The media library is deliberately NOT part of the default run. It is
|
||||
* gigabytes of immutable uploads; archiving it nightly would produce hours of
|
||||
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
|
||||
* incrementally with rsync instead.
|
||||
*
|
||||
* Exits non-zero on any failure so a cron surfaces the problem instead of
|
||||
* quietly producing corrupt files.
|
||||
*
|
||||
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
|
||||
* up the same way and restored the same way.
|
||||
*/
|
||||
class BackupCommand extends Command
|
||||
{
|
||||
protected $signature = 'takeone:backup
|
||||
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
|
||||
{--media-only : Mirror media and skip the database}
|
||||
{--keep-local : Do not delete the local copy after an off-server upload}';
|
||||
|
||||
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
|
||||
|
||||
private array $artifacts = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dir = (string) config('backup.path');
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
|
||||
$this->error("Cannot create backup directory: {$dir}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$stamp = now()->format('Ymd-His');
|
||||
$failed = false;
|
||||
|
||||
if (! $this->option('media-only')) {
|
||||
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
|
||||
}
|
||||
|
||||
if ($this->option('media') || $this->option('media-only')) {
|
||||
$failed = ! $this->mirrorMedia() || $failed;
|
||||
} else {
|
||||
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
|
||||
}
|
||||
|
||||
$this->shipOffServer();
|
||||
$this->prune($dir);
|
||||
|
||||
if ($failed) {
|
||||
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
|
||||
$this->error('Backup FAILED — see the errors above.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
|
||||
$this->newLine();
|
||||
$this->info('Backup complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/* ---------------- Database ---------------- */
|
||||
|
||||
private function backupDatabase(string $dir, string $stamp): bool
|
||||
{
|
||||
$connection = config('database.default');
|
||||
$driver = config("database.connections.{$connection}.driver");
|
||||
|
||||
return match ($driver) {
|
||||
'sqlite' => $this->backupSqlite($dir, $stamp),
|
||||
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
|
||||
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
|
||||
};
|
||||
}
|
||||
|
||||
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
|
||||
private function backupSqlite(string $dir, string $stamp): bool
|
||||
{
|
||||
$target = $dir."/db-{$stamp}.sqlite";
|
||||
|
||||
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
|
||||
// something cryptic, say so plainly — a backup that quietly produces
|
||||
// nothing is the worst possible outcome.
|
||||
if (DB::transactionLevel() > 0) {
|
||||
$this->error('Cannot snapshot the database inside an open transaction.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Harmless when the database is not in WAL mode.
|
||||
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Database snapshot failed: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->verifySqlite($target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->artifacts[] = $target;
|
||||
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the snapshot back. A file that exists but cannot be opened, or that
|
||||
* holds no migrations and no videos, is not a backup worth trusting.
|
||||
*/
|
||||
private function verifySqlite(string $path): bool
|
||||
{
|
||||
try {
|
||||
$pdo = new PDO('sqlite:'.$path);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
|
||||
$this->error("Snapshot failed integrity check: {$path}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
|
||||
|
||||
if ($migrations < 1) {
|
||||
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read a real application table too. `migrations` alone would still
|
||||
// be present in a snapshot taken of an empty database.
|
||||
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
|
||||
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Snapshot could not be verified: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function backupMysql(string $dir, string $stamp, string $connection): bool
|
||||
{
|
||||
$target = $dir."/db-{$stamp}.sql.gz";
|
||||
$c = config("database.connections.{$connection}");
|
||||
|
||||
$command = sprintf(
|
||||
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
|
||||
escapeshellarg((string) $c['host']),
|
||||
escapeshellarg((string) $c['port']),
|
||||
escapeshellarg((string) $c['username']),
|
||||
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
|
||||
escapeshellarg((string) $c['database']),
|
||||
escapeshellarg($target),
|
||||
);
|
||||
|
||||
exec($command, $out, $status);
|
||||
|
||||
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
|
||||
$this->error('mysqldump failed (exit '.$status.').');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the dump is readable and non-trivial rather than trusting the
|
||||
// exit code — a gzip of an error message also exits 0.
|
||||
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
|
||||
|
||||
if ($verify !== 0) {
|
||||
$this->error('Dump failed its gzip integrity check — discarding.');
|
||||
@unlink($target);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->artifacts[] = $target;
|
||||
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- Media ---------------- */
|
||||
|
||||
/**
|
||||
* Mirror the media library with rsync.
|
||||
*
|
||||
* Uploaded video cannot be regenerated from anything — losing it is the
|
||||
* one unrecoverable outcome on this platform. rsync is used rather than an
|
||||
* archive because the library is large and almost entirely immutable, so
|
||||
* each run copies only what is new.
|
||||
*/
|
||||
private function mirrorMedia(): bool
|
||||
{
|
||||
$destination = (string) config('backup.media_destination');
|
||||
|
||||
if ($destination === '') {
|
||||
$this->newLine();
|
||||
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
|
||||
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
|
||||
$this->error("Media destination is not reachable: {$destination}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
|
||||
|
||||
if (! $paths) {
|
||||
$this->line(' <comment>–</comment> media nothing to mirror');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --delete is opt-in and off by default: a mirror that deletes is one
|
||||
// wrong path away from erasing the copy it exists to protect.
|
||||
$flags = '-a --partial --info=stats2';
|
||||
|
||||
if (config('backup.media_delete')) {
|
||||
$flags .= ' --delete';
|
||||
}
|
||||
|
||||
$ok = true;
|
||||
|
||||
foreach ($paths as $path) {
|
||||
// Trailing slash on the source, basename on the destination, so
|
||||
// each folder lands in its own directory rather than merging.
|
||||
$command = sprintf(
|
||||
'rsync %s %s %s 2>&1',
|
||||
$flags,
|
||||
escapeshellarg(rtrim($path, '/').'/'),
|
||||
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
|
||||
);
|
||||
|
||||
exec($command, $out, $status);
|
||||
|
||||
if ($status !== 0) {
|
||||
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
|
||||
$ok = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
|
||||
}
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/* ---------------- Off-server ---------------- */
|
||||
|
||||
private function shipOffServer(): void
|
||||
{
|
||||
$disk = config('backup.disk');
|
||||
|
||||
if (! $disk) {
|
||||
$this->newLine();
|
||||
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
|
||||
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->artifacts as $artifact) {
|
||||
try {
|
||||
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
|
||||
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
|
||||
|
||||
if (! $this->option('keep-local')) {
|
||||
@unlink($artifact);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never delete the local copy when the upload failed — it is
|
||||
// the only copy left.
|
||||
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Rotation ---------------- */
|
||||
|
||||
/**
|
||||
* Delete artifacts past the retention window, but always keep the newest
|
||||
* few — a box that sat idle for a month must still have something to
|
||||
* restore from.
|
||||
*/
|
||||
private function prune(string $dir): void
|
||||
{
|
||||
$days = (int) config('backup.retain_days', 14);
|
||||
|
||||
if ($days <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keep = max(0, (int) config('backup.keep_minimum', 3));
|
||||
$cutoff = now()->subDays($days)->getTimestamp();
|
||||
$removed = 0;
|
||||
|
||||
$files = glob($dir.'/db-*') ?: [];
|
||||
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
|
||||
|
||||
foreach (array_slice($files, $keep) as $file) {
|
||||
if (filemtime($file) < $cutoff && @unlink($file)) {
|
||||
$removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
|
||||
}
|
||||
}
|
||||
|
||||
private function humanSize(string $path): string
|
||||
{
|
||||
$bytes = (int) @filesize($path);
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, 1).' '.$units[$i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
|
||||
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
|
||||
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
|
||||
* the rest. --dry-run shows what would happen without touching the DB.
|
||||
*/
|
||||
class CommentsDedupeCommand extends Command
|
||||
{
|
||||
protected $signature = 'comments:dedupe
|
||||
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
|
||||
{--dry-run : Print what would be removed; do not delete}';
|
||||
|
||||
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$window = max(1, (int) $this->option('window'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
|
||||
// We ignore the window at the SQL layer and apply it per-group in PHP:
|
||||
// that avoids a correlated subquery and is fast enough for a one-shot
|
||||
// cleanup. For very large tables, chunk by user_id.
|
||||
$groups = DB::table('comments')
|
||||
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
|
||||
->groupBy('user_id', 'video_id', 'parent_id', 'body')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No duplicate (user, video, parent, body) groups found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRemoved = 0;
|
||||
$groupsTouched = 0;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
$rows = Comment::where('user_id', $g->user_id)
|
||||
->where('video_id', $g->video_id)
|
||||
->where(function ($q) use ($g) {
|
||||
$g->parent_id === null
|
||||
? $q->whereNull('parent_id')
|
||||
: $q->where('parent_id', $g->parent_id);
|
||||
})
|
||||
->where('body', $g->body)
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keeper = $rows->first();
|
||||
$cluster = collect([$keeper]);
|
||||
$previous = $keeper;
|
||||
|
||||
foreach ($rows->slice(1) as $row) {
|
||||
// Same cluster if row is within window of the previous row —
|
||||
// which is the behavior the server guard enforces today.
|
||||
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
|
||||
$cluster->push($row);
|
||||
$previous = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cluster->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toDelete = $cluster->skip(1);
|
||||
$groupsTouched++;
|
||||
$totalRemoved += $toDelete->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
|
||||
$g->user_id,
|
||||
$g->video_id,
|
||||
$g->parent_id ?? 'null',
|
||||
$keeper->id,
|
||||
$toDelete->pluck('id')->join(','),
|
||||
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($keeper, $toDelete) {
|
||||
$ids = $toDelete->pluck('id')->all();
|
||||
|
||||
// Reparent any replies that were nested under a doomed row.
|
||||
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
|
||||
|
||||
// Move likes to the keeper, dropping dupes.
|
||||
DB::table('comment_likes')
|
||||
->whereIn('comment_id', $ids)
|
||||
->update(['comment_id' => $keeper->id]);
|
||||
|
||||
Comment::whereIn('id', $ids)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s %d duplicate rows across %d groups.',
|
||||
$dryRun ? 'Would remove' : 'Removed',
|
||||
$totalRemoved,
|
||||
$groupsTouched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateLyricsJob;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Backfill synced lyrics for existing songs. New uploads generate automatically;
|
||||
* this covers the catalogue that predates the feature.
|
||||
*
|
||||
* php artisan lyrics:generate 163 # one video (primary + every track)
|
||||
* php artisan lyrics:generate --all # every music video missing lyrics
|
||||
* php artisan lyrics:generate --all --force # regenerate even if a file exists
|
||||
*/
|
||||
class GenerateLyrics extends Command
|
||||
{
|
||||
protected $signature = 'lyrics:generate {video? : Video id} {--all : All music videos} {--force : Regenerate even when lyrics already exist}';
|
||||
protected $description = 'Generate word-level synced lyrics for songs (dispatched to the video-processing queue)';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
$force = (bool) $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time migration that moves existing songs/videos from the legacy flat
|
||||
* layout into the canonical type-segregated, per-track-folder layout described
|
||||
* in CLAUDE.md.
|
||||
*
|
||||
* Dry-run by default — pass --force to actually move files and update the DB.
|
||||
* Run this only after backing up the DB and confirming the dry-run plan looks
|
||||
* correct. The command is idempotent: rows already in the new layout are
|
||||
* skipped.
|
||||
*/
|
||||
class MigrateStorageLayout extends Command
|
||||
{
|
||||
protected $signature = 'storage:migrate-layout {--force : Apply changes (default is dry-run)} {--video= : Migrate only this video id}';
|
||||
protected $description = 'Move existing songs/videos to the type-segregated + per-track-folder layout';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
$apply = (bool) $this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NasAutoSync extends Command
|
||||
{
|
||||
@@ -19,6 +20,39 @@ class NasAutoSync extends Command
|
||||
return 0; // NAS down or disabled — nothing to do
|
||||
}
|
||||
|
||||
// Drain queued NAS file/folder moves recorded while NAS was down
|
||||
// (e.g. music-video merges). Best-effort per row; failures stay
|
||||
// in the table with attempts/last_error for later retries.
|
||||
$drained = 0; $failed = 0;
|
||||
DB::table('pending_nas_moves')->orderBy('id')->get()->each(
|
||||
function ($row) use ($nas, &$drained, &$failed) {
|
||||
try {
|
||||
$nas->renameNasPath($row->from_path, $row->to_path);
|
||||
// Best-effort prune of the now-empty source parent
|
||||
// (e.g. the source video folder after all its track
|
||||
// subfolders were adopted by the target).
|
||||
$parent = dirname($row->from_path);
|
||||
if ($parent && $parent !== '.') {
|
||||
$nas->deleteFile("{$parent}/meta.json");
|
||||
$nas->deleteFolder($parent);
|
||||
}
|
||||
DB::table('pending_nas_moves')->where('id', $row->id)->delete();
|
||||
$drained++;
|
||||
} catch (\Throwable $e) {
|
||||
DB::table('pending_nas_moves')->where('id', $row->id)->update([
|
||||
'attempts' => (int) $row->attempts + 1,
|
||||
'last_attempt_at' => now(),
|
||||
'last_error' => mb_substr($e->getMessage(), 0, 500),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
);
|
||||
if ($drained || $failed) {
|
||||
$this->info("Drained {$drained} pending NAS move(s)" . ($failed ? " (${failed} failed)" : ''));
|
||||
}
|
||||
|
||||
// Videos whose file OR thumbnail/slides are still on local disk
|
||||
$synced = 0;
|
||||
Video::with(['user', 'slides'])
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Renders the 1200×630 VS composition PNG for a match video's social-share
|
||||
* preview. Runs the same helper used by VideoController@ogImage but from a
|
||||
* CLI process context, because Chromium reliably fails to launch under
|
||||
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
|
||||
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
|
||||
* cache miss) or SportsMatchController on save.
|
||||
*/
|
||||
class RenderMatchOgImage extends Command
|
||||
{
|
||||
protected $signature = 'og:render-match {video}';
|
||||
protected $description = 'Render the OG social-share PNG for a match video';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$video = Video::find($this->argument('video'));
|
||||
if (! $video) {
|
||||
$this->error("Video {$this->argument('video')} not found.");
|
||||
return 1;
|
||||
}
|
||||
if ($video->type !== 'match' || ! $video->sportsMatch) {
|
||||
$this->error("Video {$video->id} is not a match with a sports record.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$controller = new VideoController();
|
||||
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
|
||||
$r->setAccessible(true);
|
||||
$png = $r->invoke($controller, $video);
|
||||
|
||||
if ($png === null) {
|
||||
$this->error("Render failed. See laravel.log.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RepairMergedVideo extends Command
|
||||
{
|
||||
protected $signature = 'videos:repair-merged {target : Target video encoded id (from the URL) or numeric id} {--source-id= : Force a specific source id from video_redirects} {--source-folder= : Override the source folder path (use when the redirect row has none — pre-repair-column merges)} {--dry-run : Show what would happen, do not modify anything}';
|
||||
|
||||
protected $description = 'After a music-video merge, adopt any track subfolders still stuck in the source folder on NAS as new audio tracks on the target video. Safe to re-run.';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (!$nas->isEnabled()) {
|
||||
$this->error('NAS is not reachable — cannot repair. Try again when NAS is back online.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targetArg = $this->argument('target');
|
||||
$target = is_numeric($targetArg)
|
||||
? Video::find((int) $targetArg)
|
||||
: Video::find(Video::decodeId((string) $targetArg));
|
||||
if (!$target) {
|
||||
$this->error("Target video not found: {$targetArg}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info("Target: [{$target->id}] {$target->title}");
|
||||
|
||||
$redirects = DB::table('video_redirects')->where('target_id', $target->id);
|
||||
if ($sid = $this->option('source-id')) {
|
||||
$redirects->where('source_id', (int) $sid);
|
||||
}
|
||||
$redirects = $redirects->get();
|
||||
|
||||
if ($redirects->isEmpty()) {
|
||||
$this->warn('No video_redirects rows found for this target.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$adopted = 0;
|
||||
|
||||
$folderOverride = $this->option('source-folder');
|
||||
|
||||
foreach ($redirects as $r) {
|
||||
$this->line('');
|
||||
$sourceFolder = $folderOverride ?: $r->source_folder;
|
||||
$this->info("Source id {$r->source_id} → folder: " . ($sourceFolder ?: '(unknown)'));
|
||||
|
||||
if (!$sourceFolder) {
|
||||
$this->warn(' Skipping — no source_folder recorded. Pass --source-folder="users/…/music/…" to specify.');
|
||||
continue;
|
||||
}
|
||||
// Backfill the redirect row so future runs don't need the override.
|
||||
if (!$r->source_folder && !$dryRun && $folderOverride) {
|
||||
DB::table('video_redirects')->where('source_id', $r->source_id)
|
||||
->update(['source_folder' => $folderOverride]);
|
||||
$r->source_folder = $folderOverride;
|
||||
} else {
|
||||
$r->source_folder = $sourceFolder;
|
||||
}
|
||||
|
||||
$tracksDir = $r->source_folder . '/tracks';
|
||||
$subdirs = $nas->listNasDirs($tracksDir);
|
||||
if (empty($subdirs)) {
|
||||
$this->line(' No leftover track subfolders. Cleaning up empty source folder.');
|
||||
if (!$dryRun) {
|
||||
$nas->deleteFile($r->source_folder . '/meta.json');
|
||||
$nas->deleteFolder($tracksDir);
|
||||
$nas->deleteFolder($r->source_folder);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetDir = $nas->resolveVideoDir($target);
|
||||
$nas->mkdirp("{$targetDir}/tracks");
|
||||
|
||||
foreach ($subdirs as $sub) {
|
||||
// Parse "{lang}-{origId}" to recover the language for the new track.
|
||||
$lang = 'xx';
|
||||
if (preg_match('/^([a-z]{2,10})-\d+$/i', $sub, $m)) {
|
||||
$lang = strtolower($m[1]);
|
||||
}
|
||||
|
||||
$this->line(" Adopting track folder: {$sub} (lang={$lang})");
|
||||
|
||||
if ($dryRun) { $adopted++; continue; }
|
||||
|
||||
$newTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
'language' => $lang,
|
||||
'label' => "Recovered from merge #{$r->source_id}",
|
||||
'title' => null,
|
||||
'filename' => 'audio.mp3',
|
||||
'path' => '',
|
||||
]);
|
||||
$newBasename = $nas->trackFolderName($target, $newTrack);
|
||||
$oldRel = "{$tracksDir}/{$sub}";
|
||||
$newRel = "{$targetDir}/tracks/{$newBasename}";
|
||||
|
||||
$nas->renameNasPath($oldRel, $newRel);
|
||||
|
||||
// Local mirror (if the folder happens to exist locally too).
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
if (is_dir($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
@rename($oldLocal, $newLocal);
|
||||
}
|
||||
|
||||
// Detect audio filename from local mirror if present, else default.
|
||||
$audioName = 'audio.mp3';
|
||||
foreach (['audio.mp3', 'audio.m4a', 'audio.aac', 'audio.ogg', 'audio.wav', 'audio.opus'] as $cand) {
|
||||
if (file_exists("{$newLocal}/{$cand}")) { $audioName = $cand; break; }
|
||||
}
|
||||
$newTrack->update([
|
||||
'filename' => $audioName,
|
||||
'path' => "{$newRel}/{$audioName}",
|
||||
]);
|
||||
|
||||
$adopted++;
|
||||
}
|
||||
|
||||
// Clean up now-empty source folder.
|
||||
if (!$dryRun) {
|
||||
$nas->deleteFile($r->source_folder . '/meta.json');
|
||||
$nas->deleteFolder($tracksDir);
|
||||
$nas->deleteFolder($r->source_folder);
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info(($dryRun ? '[dry-run] Would adopt ' : 'Adopted ') . $adopted . ' track folder(s).');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Resend the email-verification mail to one user or to every unverified user.
|
||||
*
|
||||
* Useful after an SMTP outage: the account was created and the user was told
|
||||
* "we sent you a link", but the mail never left because Gmail 535'd us. Once
|
||||
* the mail config is fixed, this flushes the backlog.
|
||||
*/
|
||||
class ResendVerificationCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:resend-verification
|
||||
{--user= : A specific user id OR email to resend to}
|
||||
{--all-unverified : Resend to every user with a null email_verified_at}
|
||||
{--dry-run : Print who would be mailed; do not actually send}';
|
||||
|
||||
protected $description = 'Resend the email-verification link to one user or all unverified users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$one = (string) $this->option('user');
|
||||
$all = (bool) $this->option('all-unverified');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($one === '' && ! $all) {
|
||||
$this->error('Pass either --user=<id-or-email> or --all-unverified.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($one !== '') {
|
||||
$query = ctype_digit($one)
|
||||
? User::where('id', (int) $one)
|
||||
: User::where('email', $one);
|
||||
$users = $query->get();
|
||||
} else {
|
||||
$users = User::whereNull('email_verified_at')->get();
|
||||
}
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->warn('No matching users.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$skipped++;
|
||||
$this->line(" skip #{$user->id} <{$user->email}> — already verified");
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$this->line(" would #{$user->id} <{$user->email}>");
|
||||
continue;
|
||||
}
|
||||
// sendEmailVerificationNotification is already SafeNotifier-wrapped
|
||||
// and will log instead of throwing on SMTP errors.
|
||||
$user->sendEmailVerificationNotification();
|
||||
$sent++;
|
||||
$this->line(" sent #{$user->id} <{$user->email}>");
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "Dry run complete — {$users->count()} candidates, {$skipped} already verified."
|
||||
: "Done — {$sent} sent, {$skipped} skipped.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
||||
->weeklyOn(1, '09:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
|
||||
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
|
||||
// and (when BACKUP_DISK is set) ships a copy off-server.
|
||||
$schedule->command('takeone:backup')
|
||||
->dailyAt('03:30')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,18 @@ class Countries
|
||||
* All countries keyed by ISO2 code.
|
||||
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
||||
*/
|
||||
/**
|
||||
* Resolve any ISO2 code (case-insensitive) to its full country name.
|
||||
* Falls back to the uppercased code, then to null for empty input.
|
||||
* Use this instead of printing raw two-letter codes to end users.
|
||||
*/
|
||||
public static function name(?string $iso2): ?string
|
||||
{
|
||||
$code = strtoupper(trim((string) $iso2));
|
||||
if ($code === '') return null;
|
||||
return self::all()[$code]['name'] ?? $code;
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
private const MAP = [
|
||||
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
|
||||
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
|
||||
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
|
||||
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
|
||||
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
|
||||
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
|
||||
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
|
||||
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
|
||||
'FI' => 'fi', 'FR' => 'fr',
|
||||
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
|
||||
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
|
||||
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
|
||||
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
|
||||
'JO' => 'ar', 'JP' => 'ja',
|
||||
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
|
||||
'KZ' => 'kk',
|
||||
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
|
||||
'LV' => 'lv', 'LY' => 'ar',
|
||||
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
|
||||
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
|
||||
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
|
||||
'NZ' => 'en',
|
||||
'OM' => 'ar',
|
||||
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
|
||||
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
|
||||
'QA' => 'ar',
|
||||
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
|
||||
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
|
||||
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
|
||||
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
|
||||
'TZ' => 'sw',
|
||||
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
|
||||
'VE' => 'es', 'VN' => 'vi',
|
||||
'YE' => 'ar',
|
||||
'ZA' => 'en', 'ZW' => 'en',
|
||||
];
|
||||
|
||||
public static function primary(?string $iso2): ?string
|
||||
{
|
||||
if (! $iso2) return null;
|
||||
return self::MAP[strtoupper($iso2)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BruteForceLoginAlert;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
|
||||
'details' => ['email' => $credentials['email']],
|
||||
]);
|
||||
|
||||
$this->detectBruteForce($request, $credentials['email']);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one alert to super admins when the same email has racked up
|
||||
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
|
||||
* key with the same TTL prevents the alert from re-firing on every
|
||||
* subsequent failed attempt within the same burst.
|
||||
*/
|
||||
private const BRUTE_FORCE_THRESHOLD = 5;
|
||||
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
|
||||
|
||||
private function detectBruteForce(Request $request, string $email): void
|
||||
{
|
||||
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
|
||||
$threshold = self::BRUTE_FORCE_THRESHOLD;
|
||||
|
||||
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
|
||||
// database-portable and avoids per-driver JSON operator syntax.
|
||||
$pattern = '%"email":"' . addslashes($email) . '"%';
|
||||
|
||||
$recent = AuditLog::where('action', 'user.login.failed')
|
||||
->where('created_at', '>=', now()->subMinutes($window))
|
||||
->where('details', 'like', $pattern)
|
||||
->count();
|
||||
|
||||
if ($recent < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = 'brute_force_alert:' . sha1($email);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
Cache::put($cacheKey, 1, now()->addMinutes($window));
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Forwarded-For')
|
||||
?? $request->ip()
|
||||
?? 'unknown';
|
||||
|
||||
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
|
||||
SafeNotifier::send($admin, $notification);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewUserRegistered;
|
||||
use App\Rules\NotDisposableEmail;
|
||||
use App\Rules\ReservedIdentifier;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
||||
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'birthday' => ['required', 'date', 'before:today'],
|
||||
// Age gate: must be at least 13 on sign-up day. The front-end
|
||||
// date picker already caps the year dropdown, but the server
|
||||
// must enforce it so a client bypass doesn't let a 10-year-old
|
||||
// through.
|
||||
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
|
||||
'gender' => ['required', 'in:male,female'],
|
||||
'nationality' => ['required', 'string', 'size:2'],
|
||||
]);
|
||||
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
|
||||
|
||||
// Notify all super admins of the new registration
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
|
||||
$admin->notify(new NewUserRegistered($user));
|
||||
SafeNotifier::send($admin, new NewUserRegistered($user));
|
||||
});
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +17,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,12 +36,66 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
@@ -47,12 +103,12 @@ class CommentController extends Controller
|
||||
// Reply — notify the parent comment's author
|
||||
$parent = Comment::find($request->parent_id);
|
||||
if ($parent && $parent->user_id !== $commenter->id) {
|
||||
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
|
||||
}
|
||||
} else {
|
||||
// Top-level comment — notify the video owner
|
||||
if ($video->user_id !== $commenter->id) {
|
||||
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +145,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
@@ -107,7 +194,8 @@ class CommentController extends Controller
|
||||
if ($comment->user_id !== $userId) {
|
||||
$video = $comment->video;
|
||||
if ($video) {
|
||||
$comment->user->notify(
|
||||
SafeNotifier::send(
|
||||
$comment->user,
|
||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||
);
|
||||
}
|
||||
@@ -118,4 +206,56 @@ class CommentController extends Controller
|
||||
|
||||
return response()->json(['liked' => $liked, 'count' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the "creator heart" on a comment. Only the video owner can
|
||||
* drop a heart; anyone else gets a 403.
|
||||
*/
|
||||
public function heart(Comment $comment)
|
||||
{
|
||||
$video = $comment->video;
|
||||
if (! $video || $video->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
|
||||
}
|
||||
|
||||
$comment->hearted = ! $comment->hearted;
|
||||
$comment->save();
|
||||
|
||||
return response()->json([
|
||||
'hearted' => (bool) $comment->hearted,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle "pinned" on a top-level comment. Only the video owner can
|
||||
* pin, and pinned comments sort ahead of everything else in the list.
|
||||
* At most one comment is pinned at a time — pinning a new one unpins
|
||||
* the previous.
|
||||
*/
|
||||
public function pin(Comment $comment)
|
||||
{
|
||||
$video = $comment->video;
|
||||
if (! $video || $video->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
|
||||
}
|
||||
if ($comment->parent_id) {
|
||||
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
|
||||
}
|
||||
|
||||
$wasPinned = (bool) $comment->pinned;
|
||||
if (! $wasPinned) {
|
||||
// Unpin any previously-pinned comment on this video first so
|
||||
// the UI invariant "at most one pinned" is maintained at the
|
||||
// DB level, not just in the view.
|
||||
Comment::where('video_id', $video->id)
|
||||
->where('pinned', true)
|
||||
->update(['pinned' => false]);
|
||||
}
|
||||
$comment->pinned = ! $wasPinned;
|
||||
$comment->save();
|
||||
|
||||
return response()->json([
|
||||
'pinned' => (bool) $comment->pinned,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LiveStream;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* The media server's authorisation hook.
|
||||
*
|
||||
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
|
||||
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
|
||||
* keeps every "who may do what" decision inside the application, next to the
|
||||
* users table, rather than duplicated in a media server config.
|
||||
*
|
||||
* Deny by default, in the strongest sense: if this app is down, the media
|
||||
* server can authorise nothing at all.
|
||||
*
|
||||
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
|
||||
* of the public API and reject anything arriving from elsewhere.
|
||||
*/
|
||||
class LiveAuthController extends Controller
|
||||
{
|
||||
/** Loopback addresses the media server may call from. */
|
||||
private const LOCAL_IPS = ['127.0.0.1', '::1'];
|
||||
|
||||
/**
|
||||
* Decide a publish or read attempt.
|
||||
*
|
||||
* Payload from MediaMTX: user, password, token, ip, action, path,
|
||||
* protocol, id, query.
|
||||
*/
|
||||
public function authenticate(Request $request): Response
|
||||
{
|
||||
if (! $this->fromMediaServer($request)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$action = (string) $request->input('action');
|
||||
|
||||
// Control-plane actions carry no stream path, so they are settled
|
||||
// before any attempt to resolve one.
|
||||
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
|
||||
return $this->authorizeApi();
|
||||
}
|
||||
|
||||
$path = (string) $request->input('path');
|
||||
|
||||
$stream = $this->resolveStream($path);
|
||||
|
||||
if (! $stream) {
|
||||
// An unknown path is not an error worth explaining — a publisher
|
||||
// probing for valid stream ids learns nothing from this.
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
return match ($action) {
|
||||
'publish' => $this->authorizePublish($request, $stream),
|
||||
'read' => $this->authorizeRead($stream),
|
||||
default => response('', 403),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The media server's own control API.
|
||||
*
|
||||
* It authenticates against this same hook, so without an explicit allow the
|
||||
* app could not read live state from the server it runs alongside. Safe
|
||||
* because it is already loopback-gated above — nothing off this host can
|
||||
* reach it.
|
||||
*/
|
||||
private function authorizeApi(): Response
|
||||
{
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* A publisher must present the short-lived token minted for this stream
|
||||
* when its owner pressed "Go live". The token is single-use: it is burned
|
||||
* here, so a captured one cannot start a second broadcast.
|
||||
*/
|
||||
private function authorizePublish(Request $request, LiveStream $stream): Response
|
||||
{
|
||||
// MediaMTX passes HTTP Basic credentials through as user/password.
|
||||
$presented = (string) ($request->input('password') ?: $request->input('token'));
|
||||
|
||||
if (! $stream->publishTokenMatches($presented)) {
|
||||
Log::warning('Live publish refused', [
|
||||
'stream' => $stream->public_id,
|
||||
'ip' => $request->input('ip'),
|
||||
]);
|
||||
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$stream->consumePublishToken();
|
||||
$stream->markLive();
|
||||
|
||||
Log::info('Live publish authorised', [
|
||||
'stream' => $stream->public_id,
|
||||
'user_id' => $stream->user_id,
|
||||
]);
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads are anonymous by design — a viewer fetching HLS parts carries no
|
||||
* credentials — so the gate is the stream's own state: it must be live and
|
||||
* not private. The unpredictable path is what keeps an unlisted stream
|
||||
* unlisted.
|
||||
*/
|
||||
private function authorizeRead(LiveStream $stream): Response
|
||||
{
|
||||
if (! $stream->isLive()) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle notifications from the media server.
|
||||
*
|
||||
* The auth hook only fires on the way in, so without this a stream whose
|
||||
* phone lost signal would read as live forever.
|
||||
*/
|
||||
public function hook(Request $request): Response
|
||||
{
|
||||
if (! $this->fromMediaServer($request)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$stream = $this->resolveStream((string) $request->input('path'));
|
||||
|
||||
if (! $stream) {
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
match ((string) $request->input('event')) {
|
||||
'publish' => $stream->markLive(),
|
||||
// Only a stream that is actually live can end. A late hook from a
|
||||
// previous, failed session must not kill the one just started.
|
||||
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
|
||||
'read' => $this->viewerJoined($stream),
|
||||
'unread' => $this->viewerLeft($stream),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/* ---------------- Viewer accounting ---------------- */
|
||||
|
||||
/*
|
||||
* Counted with atomic SQL increments rather than read-modify-write: viewers
|
||||
* arrive concurrently, and two joins landing together would otherwise
|
||||
* record one.
|
||||
*/
|
||||
|
||||
private function viewerJoined(LiveStream $stream): void
|
||||
{
|
||||
LiveStream::where('id', $stream->id)->increment('current_viewers');
|
||||
|
||||
// peak_viewers is a high-water mark, so it only ever moves up.
|
||||
LiveStream::where('id', $stream->id)
|
||||
->whereColumn('current_viewers', '>', 'peak_viewers')
|
||||
->update(['peak_viewers' => DB::raw('current_viewers')]);
|
||||
}
|
||||
|
||||
private function viewerLeft(LiveStream $stream): void
|
||||
{
|
||||
// Floor at zero: a missed join (a restart, a dropped hook) must not
|
||||
// drive the count negative and show "-1 watching".
|
||||
LiveStream::where('id', $stream->id)
|
||||
->where('current_viewers', '>', 0)
|
||||
->decrement('current_viewers');
|
||||
}
|
||||
|
||||
/* ---------------- Helpers ---------------- */
|
||||
|
||||
/**
|
||||
* Only the media server on this host may ask. Deliberately checks the raw
|
||||
* connection address rather than a forwarded header — a header can be
|
||||
* spoofed by anyone who can reach the app.
|
||||
*/
|
||||
private function fromMediaServer(Request $request): bool
|
||||
{
|
||||
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
|
||||
}
|
||||
|
||||
/** Turn "live/<public_id>" into the stream it names. */
|
||||
private function resolveStream(string $path): ?LiveStream
|
||||
{
|
||||
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LiveStream::where('public_id', $m[1])->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LiveChatMessage;
|
||||
use App\Models\LiveStream;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Live streaming — broadcaster and viewer surfaces.
|
||||
*
|
||||
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
|
||||
* may broadcast, mints the short-lived credential the browser publishes with,
|
||||
* and hands out the URLs. See LiveAuthController for the hook the media server
|
||||
* calls back on.
|
||||
*/
|
||||
class LiveStreamController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Watching is open; everything that creates or controls a stream needs
|
||||
// an account.
|
||||
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
|
||||
}
|
||||
|
||||
/** Live now, plus the viewer's own streams when signed in. */
|
||||
public function index(): \Illuminate\View\View
|
||||
{
|
||||
$live = LiveStream::live()
|
||||
->listed()
|
||||
->with('user')
|
||||
->latest('started_at')
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
$mine = Auth::check()
|
||||
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
|
||||
: collect();
|
||||
|
||||
return view('live.index', compact('live', 'mine'));
|
||||
}
|
||||
|
||||
/** The "start a broadcast" form. */
|
||||
public function create(): \Illuminate\View\View
|
||||
{
|
||||
return view('live.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the stream record. This does NOT start anything — it reserves an
|
||||
* identity and a path; the browser goes live from the broadcast page.
|
||||
*/
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
|
||||
]);
|
||||
|
||||
// One live broadcast per account at a time. Without this a single user
|
||||
// can pin open as many encoder slots as they can open tabs.
|
||||
$alreadyLive = LiveStream::where('user_id', Auth::id())
|
||||
->where('status', LiveStream::STATUS_LIVE)
|
||||
->exists();
|
||||
|
||||
if ($alreadyLive) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
|
||||
}
|
||||
|
||||
$stream = LiveStream::create([
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'visibility' => $data['visibility'],
|
||||
'status' => LiveStream::STATUS_IDLE,
|
||||
]);
|
||||
|
||||
return redirect()->route('live.broadcast', $stream);
|
||||
}
|
||||
|
||||
/** The broadcaster's camera page. Owner only. */
|
||||
public function broadcast(LiveStream $stream): \Illuminate\View\View
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
// An ended stream is not a dead one — the owner may go live on it
|
||||
// again from the same link.
|
||||
$stream->reArm();
|
||||
|
||||
return view('live.broadcast', [
|
||||
'stream' => $stream,
|
||||
'whipUrl' => $this->whipUrl($stream),
|
||||
'watchUrl' => route('live.watch', $stream),
|
||||
'iceServers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the publish credential.
|
||||
*
|
||||
* Separate from the page load so the token's short life starts when the
|
||||
* user actually presses "Go live", not when they opened the tab and went
|
||||
* to find a tripod.
|
||||
*/
|
||||
public function token(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
// Pressing "Go live" on a stream that previously ended restarts it
|
||||
// rather than being refused. A failed attempt must never burn the link.
|
||||
$stream->reArm();
|
||||
|
||||
return response()->json([
|
||||
'token' => $stream->issuePublishToken(),
|
||||
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
|
||||
'whip_url' => $this->whipUrl($stream),
|
||||
'ice_servers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The viewer page. */
|
||||
public function watch(LiveStream $stream): \Illuminate\View\View
|
||||
{
|
||||
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||
|
||||
$stream->loadMissing('user');
|
||||
|
||||
return view('live.watch', [
|
||||
'stream' => $stream,
|
||||
'whepUrl' => $this->whepUrl($stream),
|
||||
'hlsUrl' => $this->hlsUrl($stream),
|
||||
'isOwner' => $stream->isOwnedBy(Auth::user()),
|
||||
'iceServers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Lightweight poll for the viewer page: is it live, how many watching. */
|
||||
public function status(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||
|
||||
return response()->json([
|
||||
'status' => $stream->status,
|
||||
'live' => $stream->isLive(),
|
||||
'viewers' => $stream->current_viewers,
|
||||
'duration' => $stream->formatted_duration,
|
||||
]);
|
||||
}
|
||||
|
||||
/** End the broadcast. Owner only. */
|
||||
public function stop(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
$stream->markEnded();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $stream->status,
|
||||
'duration' => $stream->formatted_duration,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------------- URLs ---------------- */
|
||||
|
||||
/*
|
||||
* These are same-origin paths, proxied by nginx to the media server on
|
||||
* loopback. Same-origin matters: the page is served over HTTPS, and a
|
||||
* mixed-content or cross-origin media URL would be blocked by the browser
|
||||
* before WebRTC ever started.
|
||||
*/
|
||||
|
||||
private function whipUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-rtc/'.$stream->mediaPath().'/whip');
|
||||
}
|
||||
|
||||
private function whepUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-rtc/'.$stream->mediaPath().'/whep');
|
||||
}
|
||||
|
||||
private function hlsUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
|
||||
}
|
||||
|
||||
/**
|
||||
* ICE servers for the browser.
|
||||
*
|
||||
* No inbound UDP port is forwarded to this server, so a broadcaster outside
|
||||
* the local network can only reach it through a TURN relay. On the LAN the
|
||||
* host candidates work directly and TURN is never used.
|
||||
*
|
||||
* Configured through the environment so credentials never sit in the repo.
|
||||
*/
|
||||
private function iceServers(): array
|
||||
{
|
||||
$servers = [];
|
||||
|
||||
if ($stun = config('live.stun_url')) {
|
||||
$servers[] = ['urls' => $stun];
|
||||
}
|
||||
|
||||
if ($turn = config('live.turn_url')) {
|
||||
$servers[] = array_filter([
|
||||
'urls' => $turn,
|
||||
'username' => config('live.turn_username'),
|
||||
'credential' => config('live.turn_credential'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET poll: viewer asks for every message in this stream newer than
|
||||
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
|
||||
* messages — more than enough for a 3-second polling window, bounded
|
||||
* to protect the DB when a client is slow to reconnect.
|
||||
*/
|
||||
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
|
||||
{
|
||||
// Private streams are owner-only; everything else is open to viewers.
|
||||
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$since = (int) $request->query('since', 0);
|
||||
$rows = LiveChatMessage::where('livestream_id', $stream->id)
|
||||
->where('id', '>', $since)
|
||||
->orderBy('id')
|
||||
->limit(50)
|
||||
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'messages' => $rows->map(fn ($m) => [
|
||||
'id' => $m->id,
|
||||
'uid' => $m->user_id,
|
||||
'name' => $m->author_name,
|
||||
'body' => $m->body,
|
||||
'at' => $m->created_at?->toISOString(),
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a chat message. Rate-limited to roughly one message per second
|
||||
* per user via Laravel's built-in throttle, applied via the route.
|
||||
* Body is capped at 400 chars (long enough for a sentence, short enough
|
||||
* that the panel doesn't shear into a wall of text).
|
||||
*/
|
||||
public function chatSend(Request $request, LiveStream $stream): JsonResponse
|
||||
{
|
||||
if ($stream->status !== LiveStream::STATUS_LIVE) {
|
||||
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
|
||||
}
|
||||
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'max:400'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$msg = LiveChatMessage::create([
|
||||
'livestream_id' => $stream->id,
|
||||
'user_id' => $user->id,
|
||||
'author_name' => $user->name,
|
||||
'body' => trim($data['body']),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => [
|
||||
'id' => $msg->id,
|
||||
'uid' => $msg->user_id,
|
||||
'name' => $msg->author_name,
|
||||
'body' => $msg->body,
|
||||
'at' => $msg->created_at?->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'required|integer|min:1',
|
||||
'name' => 'nullable|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -45,7 +45,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'sometimes|integer|min:1',
|
||||
'name' => 'required|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -87,7 +87,7 @@ class MatchEventController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'round_id' => 'required|exists:match_rounds,id',
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -99,31 +99,10 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Get ALL previous points in this round (ordered by timestamp)
|
||||
$previousPoints = MatchPoint::where('match_round_id', $request->round_id)
|
||||
->where('timestamp_seconds', '<', $request->timestamp_seconds)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->pluck('points', 'competitor')
|
||||
->toArray();
|
||||
|
||||
// Calculate cumulative scores by summing each point value
|
||||
$scoreBlue = 0;
|
||||
$scoreRed = 0;
|
||||
|
||||
if (isset($previousPoints['blue'])) {
|
||||
$scoreBlue += $previousPoints['blue'];
|
||||
}
|
||||
if (isset($previousPoints['red'])) {
|
||||
$scoreRed += $previousPoints['red'];
|
||||
}
|
||||
|
||||
// Add current point
|
||||
if ($request->competitor === 'blue') {
|
||||
$scoreBlue += $request->points;
|
||||
} else {
|
||||
$scoreRed += $request->points;
|
||||
}
|
||||
|
||||
// Create the row first with placeholder scores, then recompute the whole
|
||||
// round's running scores. This is the only correct approach when two
|
||||
// points can share a timestamp (Blue + Red at the same moment) or when
|
||||
// a point is inserted between existing ones.
|
||||
$point = MatchPoint::create([
|
||||
'video_id' => $video->id,
|
||||
'match_round_id' => $request->round_id,
|
||||
@@ -132,21 +111,46 @@ class MatchEventController extends Controller
|
||||
'points' => $request->points,
|
||||
'competitor' => $request->competitor,
|
||||
'notes' => $request->notes,
|
||||
'score_blue' => $scoreBlue,
|
||||
'score_red' => $scoreRed,
|
||||
'score_blue' => 0,
|
||||
'score_red' => 0,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($request->round_id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute cumulative score_blue/score_red for every point in a round,
|
||||
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
|
||||
* insertion order, so Blue and Red saved at the same moment both end up
|
||||
* reflecting the running score after that moment.
|
||||
*/
|
||||
private function recomputeRoundScores(int $roundId): void
|
||||
{
|
||||
$blue = 0;
|
||||
$red = 0;
|
||||
MatchPoint::where('match_round_id', $roundId)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->each(function (MatchPoint $p) use (&$blue, &$red) {
|
||||
if ($p->competitor === 'blue') $blue += (int) $p->points;
|
||||
else $red += (int) $p->points;
|
||||
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
|
||||
$p->update(['score_blue' => $blue, 'score_red' => $red]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePoint(Request $request, MatchPoint $point)
|
||||
{
|
||||
$request->validate([
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -158,6 +162,7 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->update([
|
||||
'timestamp_seconds' => $request->timestamp_seconds,
|
||||
'action' => $request->action,
|
||||
@@ -166,9 +171,11 @@ class MatchEventController extends Controller
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point updated successfully!',
|
||||
]);
|
||||
}
|
||||
@@ -180,7 +187,9 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->delete();
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -193,11 +202,13 @@ class MatchEventController extends Controller
|
||||
public function storeReview(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -213,6 +224,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji ?? '🔥',
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -225,11 +238,13 @@ class MatchEventController extends Controller
|
||||
public function updateReview(Request $request, CoachReview $review)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -243,6 +258,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji,
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -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
|
||||
@@ -243,23 +257,19 @@ class PlaylistController extends Controller
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
];
|
||||
|
||||
// Handle thumbnail upload
|
||||
// Thumbnail: an uploaded file always wins over remove_thumbnail. The
|
||||
// modal sometimes sends both when a user clears the preview and then
|
||||
// picks a new file — without this guard the new file would be silently
|
||||
// discarded.
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
// Delete old thumbnail from NAS if exists
|
||||
if ($playlist->thumbnail) {
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
}
|
||||
|
||||
$file = $request->file('thumbnail');
|
||||
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||
}
|
||||
|
||||
// Handle thumbnail removal
|
||||
if ($request->input('remove_thumbnail') == '1') {
|
||||
if ($playlist->thumbnail) {
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
} elseif ($request->input('remove_thumbnail') == '1' && $playlist->thumbnail) {
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
|
||||
$playlist->update($updateData);
|
||||
@@ -313,7 +323,8 @@ class PlaylistController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
'audio_track_id' => 'nullable|integer|exists:video_audio_tracks,id',
|
||||
]);
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
@@ -323,7 +334,13 @@ class PlaylistController extends Controller
|
||||
abort(403, 'You cannot add this video to your playlist.');
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video);
|
||||
// Track must actually belong to this video (avoid arbitrary FK pinning).
|
||||
$audioTrackId = $request->input('audio_track_id');
|
||||
if ($audioTrackId && ! $video->audioTracks()->whereKey($audioTrackId)->exists()) {
|
||||
$audioTrackId = null;
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video, $audioTrackId ? (int) $audioTrackId : null);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
@@ -336,6 +353,39 @@ 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',
|
||||
'audio_track_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
$trackId = $request->filled('audio_track_id')
|
||||
? (int) $request->input('audio_track_id')
|
||||
: null;
|
||||
|
||||
$removed = $playlist->removeVideoWithTrack($video, $trackId);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $removed ? 'Video removed from playlist.' : 'Video was not in the playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
return back()->with('success', 'Video removed from playlist.');
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
@@ -409,6 +459,17 @@ class PlaylistController extends Controller
|
||||
'visibility' => $p->visibility,
|
||||
'thumbnail_url' => $p->thumbnail_url,
|
||||
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
||||
// Track-aware entries: each row = {video_id, audio_track_id or null}.
|
||||
// Consumers that need per-track granularity use this; older callers
|
||||
// can still rely on video_ids above.
|
||||
'entries' => \DB::table('playlist_videos')
|
||||
->where('playlist_id', $p->id)
|
||||
->select('video_id', 'audio_track_id')
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'video_id' => (int) $r->video_id,
|
||||
'audio_track_id' => $r->audio_track_id ? (int) $r->audio_track_id : null,
|
||||
])->all(),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -493,16 +554,32 @@ class PlaylistController extends Controller
|
||||
|
||||
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$tmpName = self::generateFilename($ext);
|
||||
$file->storeAs('public/thumbnails', $tmpName);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
||||
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
||||
$dir = dirname($nasPath);
|
||||
$nas->mkdirp($dir);
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
||||
$localAbs = storage_path('app/' . $nasPath);
|
||||
|
||||
// When NAS is reachable, push directly and avoid the slow timeout path.
|
||||
if ($nas->isEnabled()) {
|
||||
$tmpName = self::generateFilename($ext);
|
||||
$file->storeAs('public/thumbnails', $tmpName);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
||||
$nas->mkdirp(dirname($nasPath));
|
||||
if ($nas->putFile($tempAbs, $nasPath)) {
|
||||
@unlink($tempAbs);
|
||||
return $nasPath;
|
||||
}
|
||||
// NAS push failed mid-flight — fall through to local fallback so the
|
||||
// file isn't lost. nas:auto-sync will retry when NAS comes back.
|
||||
@mkdir(dirname($localAbs), 0755, true);
|
||||
@rename($tempAbs, $localAbs);
|
||||
return $nasPath;
|
||||
}
|
||||
|
||||
// NAS disabled / unreachable — keep on local disk at the same path
|
||||
// MediaController expects, so it serves directly without a NAS round-trip.
|
||||
@mkdir(dirname($localAbs), 0755, true);
|
||||
$file->move(dirname($localAbs), basename($localAbs));
|
||||
return $nasPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Site-wide search across videos, channels and playlists.
|
||||
*
|
||||
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
|
||||
* site. The old /videos/search endpoint still works but redirects here, so
|
||||
* nothing bookmarked earlier breaks.
|
||||
*
|
||||
* Match surface:
|
||||
* - Video title, description, tag list, primary language and the language
|
||||
* of any secondary audio track attached to it.
|
||||
* - User name, username (handle), and short bio.
|
||||
* - Playlist name and description.
|
||||
*
|
||||
* We render the existing `videos.index` view so every search result lands in
|
||||
* the same card component the browse feeds use — one place to style, one place
|
||||
* to fix bugs.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = trim((string) $request->get('q', ''));
|
||||
|
||||
if ($query === '') {
|
||||
// No query → send the user back to the browse feed instead of an
|
||||
// empty "no results" page.
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
$like = '%' . $query . '%';
|
||||
$qLower = mb_strtolower($query);
|
||||
|
||||
// ── Videos ────────────────────────────────────────────────
|
||||
// Match on text columns AND on any audio-track language that aligns
|
||||
// with the query (e.g. searching "arabic" surfaces videos that carry
|
||||
// an Arabic track even if the primary language is English).
|
||||
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
|
||||
->pluck('video_id');
|
||||
|
||||
$videoHits = Video::public()
|
||||
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
|
||||
$q->where('title', 'like', $like)
|
||||
->orWhere('description','like', $like)
|
||||
->orWhere('tags', 'like', $like)
|
||||
->orWhereRaw('LOWER(language) = ?', [$qLower])
|
||||
->orWhereIn('id', $trackVideoIds);
|
||||
})
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
$videos = $videoHits->where('is_shorts', false)->values();
|
||||
$shorts = $videoHits->where('is_shorts', true)->values();
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────
|
||||
$channels = User::where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('username','like', $like)
|
||||
->orWhere('bio', 'like', $like);
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
// ── Playlists ─────────────────────────────────────────────
|
||||
$playlists = Playlist::where('visibility', 'public')
|
||||
->where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('description','like', $like);
|
||||
})
|
||||
->withCount('videos')
|
||||
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||
->latest()
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'shorts' => $shorts,
|
||||
'playlists' => $playlists,
|
||||
'channels' => $channels,
|
||||
'query' => $query,
|
||||
'langChips' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\SportsMatch;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -34,6 +35,23 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($match, $request, $nas);
|
||||
$match->save();
|
||||
|
||||
if ($match->video && $match->video->title !== $match->title) {
|
||||
$match->video->title = $match->title;
|
||||
$match->video->save();
|
||||
}
|
||||
|
||||
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
|
||||
// get the composed VS card on first share, not the fallback frame.
|
||||
if ($match->video) {
|
||||
// Blocks the response by ~3–5s while the VS PNG renders. That
|
||||
// wait is intentional: the user typically shares the URL right
|
||||
// after saving, and WhatsApp/Facebook cache whatever they see
|
||||
// on first crawl. If the render hasn't finished when the
|
||||
// crawler hits, we'd be stuck on the thumbnail fallback in
|
||||
// every future share.
|
||||
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match saved as draft.',
|
||||
@@ -51,6 +69,21 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($sportsMatch, $request, $nas);
|
||||
$sportsMatch->save();
|
||||
|
||||
// Keep the parent Video's display title in sync with the match title
|
||||
// so the match page header, share metadata, and search results all
|
||||
// reflect the edited value.
|
||||
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
|
||||
$sportsMatch->video->title = $sportsMatch->title;
|
||||
$sportsMatch->video->save();
|
||||
}
|
||||
|
||||
if ($sportsMatch->video) {
|
||||
// Synchronous re-render on edit: guarantees the fresh VS card
|
||||
// exists at the new versioned URL before the JSON response
|
||||
// returns and the user can share the link. See renderMatchOgSync.
|
||||
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match updated.',
|
||||
@@ -166,7 +199,9 @@ class SportsMatchController extends Controller
|
||||
// Media text fields (caption/alt/credit/public). Preserve existing image
|
||||
// paths already on the record; handleImages() overwrites any replaced ones.
|
||||
$existingMedia = $match->media ?? [];
|
||||
$mediaText = $this->clean($request->input('media', []));
|
||||
// clean() returns null when the array is entirely empty — coerce back to []
|
||||
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
|
||||
$mediaText = $this->clean($request->input('media', [])) ?? [];
|
||||
if (isset($mediaText['public'])) {
|
||||
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Creator studio — the content management surface YouTube calls "YouTube
|
||||
* Studio". A skeleton for now: a filterable table of the signed-in user's
|
||||
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
|
||||
* and schedule are future work.
|
||||
*/
|
||||
class StudioController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio dashboard — defaults to the content tab.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$filter = $request->get('filter', 'all');
|
||||
|
||||
$query = Video::where('user_id', $user->id)->latest();
|
||||
|
||||
switch ($filter) {
|
||||
case 'public': $query->where('visibility', 'public'); break;
|
||||
case 'unlisted': $query->where('visibility', 'unlisted'); break;
|
||||
case 'private': $query->where('visibility', 'private'); break;
|
||||
case 'shorts': $query->where('is_shorts', true); break;
|
||||
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
|
||||
}
|
||||
|
||||
$videos = $query->paginate(24)->withQueryString();
|
||||
|
||||
// Headline numbers for the top bar.
|
||||
$totals = [
|
||||
'total' => Video::where('user_id', $user->id)->count(),
|
||||
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
|
||||
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
|
||||
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
|
||||
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
|
||||
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
|
||||
'total_views' => \DB::table('video_views')
|
||||
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('studio.index', [
|
||||
'videos' => $videos,
|
||||
'filter' => $filter,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
|
||||
* Guards against setting visibility on videos the user doesn't own, so a
|
||||
* crafted request can't flip anyone else's content.
|
||||
*/
|
||||
public function bulkVisibility(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'video_ids' => ['required', 'array', 'min:1'],
|
||||
'video_ids.*' => ['integer'],
|
||||
'visibility' => ['required', 'in:public,unlisted,private'],
|
||||
]);
|
||||
|
||||
$updated = Video::whereIn('id', $request->video_ids)
|
||||
->where('user_id', Auth::id())
|
||||
->update(['visibility' => $request->visibility]);
|
||||
|
||||
return response()->json(['updated' => $updated]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete. Same owner-only guard as above.
|
||||
*/
|
||||
public function bulkDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'video_ids' => ['required', 'array', 'min:1'],
|
||||
'video_ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$deleted = Video::whereIn('id', $request->video_ids)
|
||||
->where('user_id', Auth::id())
|
||||
->delete();
|
||||
|
||||
return response()->json(['deleted' => $deleted]);
|
||||
}
|
||||
}
|
||||
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
|
||||
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────
|
||||
$disk = Storage::disk('public');
|
||||
// NAS is the primary store; walking the local disk here would miss
|
||||
// every file that's been pushed to NAS and purged locally. We use
|
||||
// the DB-captured file sizes instead: videos.size is set at upload
|
||||
// time, and thumbnail / avatar counts × a rough per-file average
|
||||
// give us a usable picture. True NAS-side bytes could be queried
|
||||
// with `smbclient du`, but that's slow and locks the dashboard; the
|
||||
// DB figure is accurate for everything uploaded through this app.
|
||||
$sizeVideos = (int) Video::sum('size'); // bytes
|
||||
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
|
||||
$avatarCount = User::whereNotNull('avatar')->count();
|
||||
$bannerCount = User::whereNotNull('banner')->count();
|
||||
$postImageCount = \DB::table('post_images')->count();
|
||||
|
||||
$sizeVideos = $this->dirSize($disk, 'videos');
|
||||
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
||||
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
||||
$sizeImages = $this->dirSize($disk, 'images');
|
||||
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
|
||||
// Images are variable but ~120 KB is a solid conservative estimate
|
||||
// for a web-sized JPEG/PNG upload.
|
||||
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
|
||||
$sizeThumbnails = $thumbnailCount * 120 * 1024;
|
||||
$sizeAvatars = $avatarCount * 120 * 1024;
|
||||
$totalPublicSize = $sizeVideos + $sizeImages;
|
||||
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
||||
|
||||
// Convert to MB
|
||||
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
||||
$storage = [
|
||||
'videos' => $toMb($sizeVideos),
|
||||
@@ -506,7 +517,7 @@ class SuperAdminController extends Controller
|
||||
foreach ($uniqueViewers as $viewer) {
|
||||
if (!$viewer->country) continue;
|
||||
if (!isset($countryMap[$viewer->country])) {
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
|
||||
}
|
||||
$countryMap[$viewer->country]['total']++;
|
||||
}
|
||||
@@ -772,7 +783,7 @@ class SuperAdminController extends Controller
|
||||
->groupBy('users.id', 'users.name', 'users.avatar')
|
||||
->orderByDesc('video_count')->take($limit)->get();
|
||||
return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id,
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : asset('images/default-avatar.svg'),
|
||||
'name' => $u->name,
|
||||
'videos' => $u->video_count,
|
||||
'views' => $u->total_views,
|
||||
@@ -846,48 +857,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 +1062,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 +1163,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)
|
||||
|
||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['channel']);
|
||||
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
|
||||
}
|
||||
|
||||
// Typeahead search for members (used by the "Share to member" picker)
|
||||
@@ -246,7 +246,14 @@ class UserController extends Controller
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
// Views first, likes as the tiebreak, created_at as the final
|
||||
// tiebreak so two videos with the same view+like counts don't
|
||||
// flip order between page loads.
|
||||
$baseQuery
|
||||
->withCount(['viewers as viewers_count', 'likes as likes_count'])
|
||||
->orderByDesc('viewers_count')
|
||||
->orderByDesc('likes_count')
|
||||
->latest();
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
@@ -262,8 +269,16 @@ class UserController extends Controller
|
||||
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Header stats must not leak unlisted/private video view counts to
|
||||
// visitors. Owners (and ?preview=1 is handled above by setting
|
||||
// $isOwner=false) see totals across every video they uploaded;
|
||||
// visitors see totals across public videos only — matching what
|
||||
// the videos count row already shows.
|
||||
$viewVideoIds = $isOwner
|
||||
? $user->videos()->pluck('id')
|
||||
: Video::public()->where('user_id', $user->id)->pluck('id');
|
||||
$totalViews = \DB::table('video_views')
|
||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||
->whereIn('video_id', $viewVideoIds)
|
||||
->count();
|
||||
|
||||
// Filter social links by visibility for the current viewer
|
||||
@@ -310,29 +325,41 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
// Watch history
|
||||
public function history()
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('watched_at', 'desc')
|
||||
->pluck('video_id')
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
// Latest-first by watched_at. Paginate at the join so a user who
|
||||
// has watched tens of thousands of videos doesn't pull every row
|
||||
// into memory on each page load. Fetch the videos keyed by latest
|
||||
// view so sort order is deterministic even when the same video has
|
||||
// been watched more than once.
|
||||
//
|
||||
// Each history row is one (video_views + videos) pair, with the
|
||||
// uniqueness constraint deferred to presentation: duplicates per
|
||||
// video are expected (one row per view event) and the UI collapses
|
||||
// them with view-count badges.
|
||||
$per = 48;
|
||||
$rows = \DB::table('video_views')
|
||||
->join('videos', 'videos.id', '=', 'video_views.video_id')
|
||||
->where('video_views.user_id', $user->id)
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
$q->where('videos.visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortBy(function ($video) use ($videoIds) {
|
||||
return $videoIds->search($video->id);
|
||||
});
|
||||
->orderBy('video_views.watched_at', 'desc')
|
||||
->select('videos.id as id', 'video_views.watched_at')
|
||||
->simplePaginate($per);
|
||||
|
||||
return view('user.history', compact('videos'));
|
||||
$videoIds = collect($rows->items())->pluck('id')->unique();
|
||||
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
|
||||
$videos = collect($rows->items())
|
||||
->map(fn ($r) => $videoMap->get($r->id))
|
||||
->filter()
|
||||
->unique('id') // collapse re-watches of the same video on this page
|
||||
->values();
|
||||
|
||||
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
|
||||
}
|
||||
|
||||
// Clear watch history
|
||||
@@ -345,18 +372,82 @@ class UserController extends Controller
|
||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON endpoint powering the "Load more" button on the channel Videos
|
||||
* tab. Takes a username + optional cursor and returns a page of
|
||||
* server-rendered video cards + next cursor. Reuses the cursorPaginate
|
||||
* pattern Phase 2 established for the home feed.
|
||||
*/
|
||||
public function channelVideosMore(Request $request, string $username)
|
||||
{
|
||||
$owner = User::where('username', $username)->firstOrFail();
|
||||
$viewer = Auth::user();
|
||||
$isOwner = $viewer && $viewer->id === $owner->id;
|
||||
|
||||
$query = $isOwner
|
||||
? Video::where('user_id', $owner->id)
|
||||
: Video::public()->where('user_id', $owner->id);
|
||||
|
||||
$paginator = $query->where('is_shorts', false)
|
||||
->latest('id')
|
||||
->cursorPaginate(24);
|
||||
|
||||
$html = '';
|
||||
foreach ($paginator->items() as $video) {
|
||||
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'items' => $html,
|
||||
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions feed — the public videos uploaded by every channel the
|
||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||
* the sidebar's "Subscriptions" item used to point at. Thin on purpose:
|
||||
* reuses the existing video-card component via the videos/index view.
|
||||
*/
|
||||
public function subscriptionsFeed()
|
||||
{
|
||||
$me = Auth::user();
|
||||
$channelIds = $me->subscriptions()->pluck('users.id');
|
||||
|
||||
$videos = Video::public()
|
||||
->whereIn('user_id', $channelIds)
|
||||
->notShorts()
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'filter' => 'subscriptions',
|
||||
'langChips' => [],
|
||||
'shorts' => collect(),
|
||||
'matches' => collect(),
|
||||
'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Liked videos
|
||||
public function liked()
|
||||
{
|
||||
$user = Auth::user();
|
||||
// Include private videos in liked (user's own private videos)
|
||||
// Include private videos in liked (user's own private videos).
|
||||
// Cursor pagination matches the Phase 2 feed endpoints — stable
|
||||
// across inserts (unlike ?page=N which can duplicate or skip rows
|
||||
// when a new like lands between page fetches) and loads a bounded
|
||||
// slice regardless of how many thousands of likes the user has.
|
||||
$videos = $user->likes()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
->latest('videos.id')
|
||||
->cursorPaginate(24);
|
||||
|
||||
return view('user.liked', compact('videos'));
|
||||
}
|
||||
@@ -394,14 +485,42 @@ class UserController extends Controller
|
||||
} else {
|
||||
$video->likes()->attach($user->id);
|
||||
$liked = true;
|
||||
// Toggling to like clears any existing dislike — mirrors YT's
|
||||
// behavior where a video can be liked OR disliked, not both.
|
||||
$video->dislikes()->detach($user->id);
|
||||
if ($video->user_id && $video->user_id !== $user->id) {
|
||||
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'liked' => $liked,
|
||||
'like_count' => $video->like_count,
|
||||
'liked' => $liked,
|
||||
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
|
||||
'like_count' => $video->like_count,
|
||||
'dislike_count' => $video->dislike_count,
|
||||
]);
|
||||
}
|
||||
|
||||
// Toggle dislike (API). Mutually exclusive with the like state.
|
||||
public function toggleDislike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($video->isDislikedBy($user)) {
|
||||
$video->dislikes()->detach($user->id);
|
||||
$disliked = false;
|
||||
} else {
|
||||
$video->dislikes()->attach($user->id);
|
||||
$disliked = true;
|
||||
// Clear any existing like.
|
||||
$video->likes()->detach($user->id);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'disliked' => $disliked,
|
||||
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
|
||||
'like_count' => $video->like_count,
|
||||
'dislike_count' => $video->dislike_count,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -565,14 +684,21 @@ class UserController extends Controller
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['avatar' => $relPath]);
|
||||
// Bump updated_at even when $relPath is unchanged (avatar.png -> avatar.png)
|
||||
// so avatar_url's ?v cache-buster changes and every page shows the new image.
|
||||
$user->avatar = $relPath;
|
||||
$user->updated_at = now();
|
||||
$user->save();
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncAvatar($user, $destPath);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
// Return the canonical media URL. The url from image.upload points at the
|
||||
// temp file we just moved to NAS and deleted, so the client must use this
|
||||
// instead — otherwise the avatar shows broken until the next page load.
|
||||
return response()->json(['ok' => true, 'url' => route('media.avatar', $relPath)]);
|
||||
}
|
||||
|
||||
public function updateBanner(Request $request)
|
||||
@@ -599,13 +725,19 @@ class UserController extends Controller
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['banner' => $relPath]);
|
||||
// Bump updated_at even when $relPath is unchanged so banner_url's ?v buster
|
||||
// changes and the new banner shows everywhere (see updateAvatar).
|
||||
$user->banner = $relPath;
|
||||
$user->updated_at = now();
|
||||
$user->save();
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncCover($user, $destPath);
|
||||
$nas->deleteLocalBanner($user);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
// Return the canonical media URL (see updateAvatar) so the client updates
|
||||
// the banner in place instead of pointing at the deleted temp file.
|
||||
return response()->json(['ok' => true, 'url' => route('media.banner', $relPath)]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Services\VideoMergeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class VideoMergeController extends Controller
|
||||
{
|
||||
public function __construct(private VideoMergeService $merger) {}
|
||||
|
||||
/**
|
||||
* List the current user's other music videos that $target can be merged with.
|
||||
*/
|
||||
public function candidates(Request $request, Video $target)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
if ($target->type !== 'music') {
|
||||
return response()->json(['error' => 'Only music videos can be merged.'], 422);
|
||||
}
|
||||
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$query = Video::where('user_id', Auth::id())
|
||||
->where('type', 'music')
|
||||
->where('id', '!=', $target->id)
|
||||
->orderByDesc('created_at');
|
||||
if ($q !== '') {
|
||||
$query->where('title', 'like', '%' . $q . '%');
|
||||
}
|
||||
$items = $query->limit(30)->get(['id', 'title', 'thumbnail', 'created_at'])->map(function ($v) {
|
||||
return [
|
||||
'id' => $v->getRouteKey(),
|
||||
'title' => $v->title,
|
||||
'thumbnail' => $v->thumbnail_url,
|
||||
'created' => $v->created_at?->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['items' => $items]);
|
||||
}
|
||||
|
||||
public function preview(Video $target, Video $source)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
$this->authorizeOwner($source);
|
||||
if ($target->type !== 'music' || $source->type !== 'music') {
|
||||
return response()->json(['error' => 'Only music videos can be merged.'], 422);
|
||||
}
|
||||
if ($target->id === $source->id) {
|
||||
return response()->json(['error' => 'Cannot merge a video with itself.'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'source' => [
|
||||
'id' => $source->getRouteKey(),
|
||||
'title' => $source->title,
|
||||
'thumb' => $source->thumbnail_url,
|
||||
],
|
||||
'target' => [
|
||||
'id' => $target->getRouteKey(),
|
||||
'title' => $target->title,
|
||||
'thumb' => $target->thumbnail_url,
|
||||
],
|
||||
'counts' => $this->merger->preview($source, $target),
|
||||
]);
|
||||
}
|
||||
|
||||
public function merge(Request $request, Video $target, Video $source)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
$this->authorizeOwner($source);
|
||||
|
||||
try {
|
||||
$this->merger->merge($source, $target);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Video merge failed', [
|
||||
'source' => $source->id,
|
||||
'target' => $target->id,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['error' => 'Merge failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'redirect' => route('videos.show', $target),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOwner(Video $video): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) abort(401);
|
||||
if ($user->id !== $video->user_id && !$user->isSuperAdmin()) abort(403);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -13,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CompressVideoJob implements ShouldQueue
|
||||
{
|
||||
@@ -30,7 +27,6 @@ class CompressVideoJob implements ShouldQueue
|
||||
{
|
||||
$video = $this->video;
|
||||
|
||||
// Get original file path
|
||||
$originalPath = storage_path('app/' . $video->path);
|
||||
|
||||
if (!file_exists($originalPath)) {
|
||||
@@ -38,98 +34,114 @@ class CompressVideoJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Create compressed file alongside the original
|
||||
$compressedFilename = 'compressed_' . $video->filename;
|
||||
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
|
||||
|
||||
try {
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||
]);
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
$ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg';
|
||||
$ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe';
|
||||
|
||||
// Detect the source video codec. Browsers on Chrome/Firefox/Android can't
|
||||
// decode HEVC (H.265/hev1/hvc1) — those sources must be replaced with H.264
|
||||
// even if the transcode is larger, so downloads and MP4 fallback stay playable.
|
||||
$srcCodec = '';
|
||||
try {
|
||||
$probeOut = [];
|
||||
exec(escapeshellcmd($ffprobeBin) . ' -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($originalPath), $probeOut);
|
||||
$srcCodec = strtolower(trim($probeOut[0] ?? ''));
|
||||
} catch (\Throwable $e) {}
|
||||
$forceReplace = in_array($srcCodec, ['hevc', 'h265', 'hev1', 'hvc1'], true);
|
||||
|
||||
// Verify the GPU is actually reachable and able to encode before sending
|
||||
// the file to it; otherwise fall back to CPU so the job never hangs.
|
||||
$gpuEnabled = Setting::gpuUsable();
|
||||
$encoder = Setting::gpuEncoder();
|
||||
$preset = Setting::gpuPreset();
|
||||
$encoder = Setting::gpuEncoder(); // h264_nvenc / libx264
|
||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||
$device = Setting::gpuDevice();
|
||||
$hwaccel = Setting::gpuHwaccel();
|
||||
|
||||
if ($gpuEnabled) {
|
||||
$videoPasses = [
|
||||
"-c:v {$encoder}",
|
||||
"-preset {$preset}",
|
||||
'-rc vbr',
|
||||
'-cq 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
"-gpu {$device}",
|
||||
];
|
||||
// Build ffmpeg command directly — php-ffmpeg's X264 format validator
|
||||
// only accepts 'libx264' and throws on 'h264_nvenc'.
|
||||
$cmd = [escapeshellcmd($ffmpegBin), '-y'];
|
||||
|
||||
if ($gpuEnabled && $hwaccel !== 'none') {
|
||||
$cmd[] = "-hwaccel {$hwaccel}";
|
||||
$cmd[] = "-hwaccel_device {$device}";
|
||||
}
|
||||
|
||||
$cmd[] = '-i ' . escapeshellarg($originalPath);
|
||||
$cmd[] = "-c:v {$encoder}";
|
||||
|
||||
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-rc vbr';
|
||||
$cmd[] = '-cq 23';
|
||||
$cmd[] = "-gpu {$device}";
|
||||
} else {
|
||||
$videoPasses = [
|
||||
'-c:v libx264',
|
||||
'-preset fast',
|
||||
'-crf 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
];
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-crf 23';
|
||||
}
|
||||
$audioPasses = ['-c:a aac', '-b:a 192k'];
|
||||
|
||||
$format = new X264('aac', $encoder);
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
foreach ($audioPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
$ffmpegVideo->save($format, $compressedPath);
|
||||
$cmd[] = '-profile:v high';
|
||||
$cmd[] = '-pix_fmt yuv420p';
|
||||
$cmd[] = '-c:a aac';
|
||||
$cmd[] = '-b:a 192k';
|
||||
$cmd[] = '-movflags +faststart';
|
||||
$cmd[] = escapeshellarg($compressedPath);
|
||||
|
||||
$fullCmd = implode(' ', $cmd) . ' 2>&1';
|
||||
|
||||
Log::info('CompressVideoJob: Starting', [
|
||||
'video_id' => $video->id,
|
||||
'src_codec' => $srcCodec,
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
]);
|
||||
|
||||
$output = [];
|
||||
exec($fullCmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$tail = implode("\n", array_slice($output, -30));
|
||||
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
|
||||
}
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
if (file_exists($compressedPath)) {
|
||||
$originalSize = filesize($originalPath);
|
||||
$originalSize = filesize($originalPath);
|
||||
$compressedSize = filesize($compressedPath);
|
||||
|
||||
// Only use compressed file if it's smaller
|
||||
if ($compressedSize < $originalSize) {
|
||||
// Delete original and rename compressed
|
||||
if ($compressedSize < $originalSize || $forceReplace) {
|
||||
unlink($originalPath);
|
||||
rename($compressedPath, $originalPath);
|
||||
|
||||
// Update video record
|
||||
$video->update([
|
||||
'size' => $compressedSize,
|
||||
'filename' => $video->filename, // Keep same filename
|
||||
'size' => $compressedSize,
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%',
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
'src_codec' => $srcCodec,
|
||||
'forced' => $forceReplace && $compressedSize >= $originalSize,
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
unlink($compressedPath);
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original');
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]);
|
||||
}
|
||||
}
|
||||
|
||||
$video->update(['status' => 'ready']);
|
||||
|
||||
// Chain to HLS generation for GPU-accelerated adaptive playback
|
||||
\App\Jobs\GenerateHlsJob::dispatch($video);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
||||
$video->update(['status' => 'ready']); // Mark as ready anyway
|
||||
if (file_exists($compressedPath)) @unlink($compressedPath);
|
||||
$video->update(['status' => 'ready']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
|
||||
* Original words are preserved verbatim; emojis are layered on top (in-line +
|
||||
* trailing, multiple per line) per the admin's decoration prompt.
|
||||
*
|
||||
* Runs as its own job so a flaky LLM call can never delay or fail a successful
|
||||
* transcription. Safe to re-run — already-decorated lines are skipped, so a
|
||||
* second pass only fills in gaps.
|
||||
*/
|
||||
class DecorateLyricsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 600;
|
||||
public int $tries = 1;
|
||||
|
||||
/** Languages written without spaces between words (mirrors transcribe.py). */
|
||||
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
||||
|
||||
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Generate word-level synced lyrics for one audio track (the video's primary
|
||||
* audio when $trackId is null, otherwise a specific extra-language track).
|
||||
*
|
||||
* Output is a per-track lyrics JSON written through NasSyncService::putLyrics()
|
||||
* — source-of-truth, synced to NAS, never under cache/. Runs the GPU pipeline
|
||||
* exactly once; playback just loads the file afterwards.
|
||||
*/
|
||||
class GenerateLyricsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 3600;
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@ class CoachReview extends Model
|
||||
'note',
|
||||
'coach_name',
|
||||
'emoji',
|
||||
'position_x',
|
||||
'position_y',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'position_x' => 'float',
|
||||
'position_y' => 'float',
|
||||
'start_time_seconds' => 'float',
|
||||
'end_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'body',
|
||||
'hearted',
|
||||
'pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hearted' => 'boolean',
|
||||
'pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LiveChatMessage extends Model
|
||||
{
|
||||
protected $table = 'live_chat_messages';
|
||||
|
||||
public $timestamps = false; // explicit created_at only, set via useCurrent()
|
||||
|
||||
protected $fillable = [
|
||||
'livestream_id',
|
||||
'user_id',
|
||||
'author_name',
|
||||
'body',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function livestream()
|
||||
{
|
||||
return $this->belongsTo(LiveStream::class, 'livestream_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A live broadcast.
|
||||
*
|
||||
* The row is the authority on whether a stream may exist; the media server
|
||||
* holds no state of its own and asks this table (through LiveAuthController)
|
||||
* before it accepts a publisher or a viewer.
|
||||
*/
|
||||
class LiveStream extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'livestreams';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'public_id', 'title', 'description',
|
||||
'status', 'visibility', 'video_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The publish token is a credential. Keeping it out of the default array
|
||||
* form means it cannot be leaked by a careless toJson() on a model that
|
||||
* reached a view or an API response.
|
||||
*/
|
||||
protected $hidden = ['publish_token', 'publish_token_expires_at'];
|
||||
|
||||
protected $casts = [
|
||||
'publish_token_expires_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_IDLE = 'idle';
|
||||
public const STATUS_LIVE = 'live';
|
||||
public const STATUS_ENDED = 'ended';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
/** How long a freshly minted publish token stays valid. */
|
||||
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Every stream gets an unpredictable public id, always — never left to
|
||||
// a caller to remember.
|
||||
static::creating(function (self $stream) {
|
||||
$stream->public_id ??= self::newPublicId();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Identity ---------------- */
|
||||
|
||||
/**
|
||||
* 32 chars of alphanumeric entropy. Also the media path segment, which is
|
||||
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
|
||||
* the media server is configured with.
|
||||
*/
|
||||
public static function newPublicId(): string
|
||||
{
|
||||
do {
|
||||
$id = Str::random(32);
|
||||
} while (self::where('public_id', $id)->exists());
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Route model binding uses the unpredictable id, never the numeric key. */
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'public_id';
|
||||
}
|
||||
|
||||
/** The path the media server knows this stream by. */
|
||||
public function mediaPath(): string
|
||||
{
|
||||
return 'live/'.$this->public_id;
|
||||
}
|
||||
|
||||
/* ---------------- Relations ---------------- */
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** The recording, once a finished stream has been published as a video. */
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
/* ---------------- State ---------------- */
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_LIVE;
|
||||
}
|
||||
|
||||
public function isOwnedBy(?User $user): bool
|
||||
{
|
||||
return $user !== null && $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this viewer watch?
|
||||
*
|
||||
* Both public and unlisted streams are watchable by anyone holding the
|
||||
* link — the difference is only whether the stream is listed. Ownership
|
||||
* additionally grants access before it goes live, so the broadcaster can
|
||||
* open their own watch page to check themselves.
|
||||
*/
|
||||
public function isWatchableBy(?User $user): bool
|
||||
{
|
||||
if ($this->isOwnedBy($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Visibility first — private streams stay owner-only no matter what.
|
||||
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Live streams are watchable while they're on; ended streams remain
|
||||
// permalinked so viewers see "Stream ended" and the replay CTA
|
||||
// instead of a hard 404.
|
||||
return $this->isLive() || $this->status === self::STATUS_ENDED;
|
||||
}
|
||||
|
||||
/* ---------------- Publish credential ---------------- */
|
||||
|
||||
/**
|
||||
* Mint a short-lived, single-use token for the broadcaster's browser.
|
||||
*
|
||||
* Returned in plaintext to the owner exactly once, at the moment they press
|
||||
* "Go live". Stored so the media server's auth hook can verify it, then
|
||||
* consumed on first successful publish.
|
||||
*/
|
||||
public function issuePublishToken(): string
|
||||
{
|
||||
$token = Str::random(64);
|
||||
|
||||
$this->forceFill([
|
||||
'publish_token' => hash('sha256', $token),
|
||||
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
|
||||
])->save();
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Constant-time check of a presented token against the stored hash. */
|
||||
public function publishTokenMatches(?string $presented): bool
|
||||
{
|
||||
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->publish_token_expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($this->publish_token, hash('sha256', $presented));
|
||||
}
|
||||
|
||||
/** Burn the token so a captured one cannot be replayed. */
|
||||
public function consumePublishToken(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'publish_token' => null,
|
||||
'publish_token_expires_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/* ---------------- Transitions ---------------- */
|
||||
|
||||
public function markLive(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_LIVE,
|
||||
'started_at' => $this->started_at ?? now(),
|
||||
'last_seen_at' => now(),
|
||||
'ended_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an ended stream back into a startable state.
|
||||
*
|
||||
* A stream is a reusable channel, not a one-shot: the owner keeps the same
|
||||
* link and can go live on it again. Without this, any failed start — a lost
|
||||
* signal, a refused camera, a server hiccup — would kill the stream
|
||||
* permanently and force a new link on every retry.
|
||||
*/
|
||||
public function reArm(): void
|
||||
{
|
||||
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_IDLE,
|
||||
'ended_at' => null,
|
||||
'current_viewers' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markEnded(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ENDED,
|
||||
'ended_at' => now(),
|
||||
'current_viewers' => 0,
|
||||
'publish_token' => null,
|
||||
'publish_token_expires_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/* ---------------- Display ---------------- */
|
||||
|
||||
public function getDurationSecondsAttribute(): ?int
|
||||
{
|
||||
if (! $this->started_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
|
||||
}
|
||||
|
||||
public function getFormattedDurationAttribute(): string
|
||||
{
|
||||
$seconds = $this->duration_seconds;
|
||||
|
||||
if ($seconds === null) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
return $seconds >= 3600
|
||||
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
|
||||
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
|
||||
}
|
||||
|
||||
/* ---------------- Scopes ---------------- */
|
||||
|
||||
public function scopeLive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_LIVE);
|
||||
}
|
||||
|
||||
public function scopeListed($query)
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ class MatchPoint extends Model
|
||||
'score_red',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'timestamp_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
@@ -19,7 +19,7 @@ class MatchRound extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_time_seconds' => 'integer',
|
||||
'start_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
+124
-8
@@ -17,10 +17,12 @@ class Playlist extends Model
|
||||
'visibility',
|
||||
'is_default',
|
||||
'share_token',
|
||||
'view_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'view_count' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
@@ -32,7 +34,7 @@ class Playlist extends Model
|
||||
public function videos()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'playlist_videos')
|
||||
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->withPivot('position', 'audio_track_id', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -219,24 +296,48 @@ class Playlist extends Model
|
||||
}
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Video $video)
|
||||
// Add video to playlist. When $audioTrackId is null the primary track
|
||||
// plays; otherwise the specified language track is pinned to this row.
|
||||
// Same video with a *different* track becomes a separate row.
|
||||
public function addVideo(Video $video, ?int $audioTrackId = null)
|
||||
{
|
||||
if ($this->hasVideo($video)) {
|
||||
if ($this->hasVideoWithTrack($video, $audioTrackId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||
|
||||
$this->videos()->attach($video->id, [
|
||||
'position' => $maxPosition + 1,
|
||||
'added_at' => now(),
|
||||
'position' => $maxPosition + 1,
|
||||
'audio_track_id' => $audioTrackId,
|
||||
'added_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
// Remove a specific (video, track) row from the playlist. When
|
||||
// $audioTrackId is null, only the "primary track" row is removed —
|
||||
// language-pinned rows for the same video are left in place.
|
||||
public function removeVideoWithTrack(Video $video, ?int $audioTrackId = null)
|
||||
{
|
||||
$query = \DB::table('playlist_videos')
|
||||
->where('playlist_id', $this->id)
|
||||
->where('video_id', $video->id);
|
||||
if ($audioTrackId === null) {
|
||||
$query->whereNull('audio_track_id');
|
||||
} else {
|
||||
$query->where('audio_track_id', $audioTrackId);
|
||||
}
|
||||
$deleted = $query->delete();
|
||||
|
||||
if ($deleted) $this->reorderPositions();
|
||||
|
||||
return (bool) $deleted;
|
||||
}
|
||||
|
||||
// Remove *all* rows for a given video (any track). Preserved for
|
||||
// callers that don't care about the track distinction.
|
||||
public function removeVideo(Video $video)
|
||||
{
|
||||
if (! $this->hasVideo($video)) {
|
||||
@@ -251,6 +352,21 @@ class Playlist extends Model
|
||||
return true;
|
||||
}
|
||||
|
||||
// Does this playlist contain (video, track)? track=null matches the
|
||||
// "primary track" row specifically, not any row for the video.
|
||||
public function hasVideoWithTrack(Video $video, ?int $audioTrackId = null): bool
|
||||
{
|
||||
$query = \DB::table('playlist_videos')
|
||||
->where('playlist_id', $this->id)
|
||||
->where('video_id', $video->id);
|
||||
if ($audioTrackId === null) {
|
||||
$query->whereNull('audio_track_id');
|
||||
} else {
|
||||
$query->where('audio_track_id', $audioTrackId);
|
||||
}
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
// Reorder positions after removal
|
||||
protected function reorderPositions()
|
||||
{
|
||||
|
||||
@@ -34,6 +34,90 @@ class SportsMatch extends Model
|
||||
'reviews' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Shape the record into exactly what the match page header renders. Every value
|
||||
* is null-safe so a video with no/partial match data degrades to just its title
|
||||
* instead of falling back to hard-coded demo content.
|
||||
*/
|
||||
public function headerData(): array
|
||||
{
|
||||
$p = $this->participants ?? [];
|
||||
$c = $this->competition ?? [];
|
||||
$v = $this->venue ?? [];
|
||||
$m = $this->media ?? [];
|
||||
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
|
||||
|
||||
return [
|
||||
'sport' => $trim($this->sport),
|
||||
'blue' => [
|
||||
'name' => $trim($this->participant1_name),
|
||||
'club' => $trim($p['p1_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
||||
// Uploader form (sports-match modal) stores these under the
|
||||
// canonical media.* keys — mirror them to headerData() so the
|
||||
// scoreboard/VS partials get real image paths.
|
||||
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
|
||||
],
|
||||
'red' => [
|
||||
'name' => $trim($this->participant2_name),
|
||||
'club' => $trim($p['p2_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
||||
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
|
||||
],
|
||||
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||
'division' => $trim($c['division'] ?? null),
|
||||
// Sub-header "stage" shown between the two yellow lines above
|
||||
// the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
|
||||
// the SportsMatch->title column — the form's "Match title" input.
|
||||
'stage' => $trim($this->title),
|
||||
// "Event title" = the championship / tournament name shown above the
|
||||
// fighters. Prefer the SportsMatch->event_name column (the field the
|
||||
// uploader labels "Event title"), fall back to competition.championship_name.
|
||||
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
|
||||
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
|
||||
'court' => $trim($c['court'] ?? null),
|
||||
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
||||
'match_date' => $this->match_date?->format('Y-m-d'),
|
||||
'referee' => [
|
||||
'name' => $trim($this->referee_name),
|
||||
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
||||
],
|
||||
'venue' => [
|
||||
'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
|
||||
'map_link' => $trim($v['map_link'] ?? null),
|
||||
],
|
||||
'event_logo' => $trim($m['event_poster'] ?? null),
|
||||
/*
|
||||
* Per-video overlay defaults set by the uploader. Shape:
|
||||
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
|
||||
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
|
||||
* Anything not set falls back to the app defaults in the JS.
|
||||
*/
|
||||
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
/** Resolve a country name or ISO2 code to a lowercase flag-icons code (or null). */
|
||||
private function flagFor(?string $country): ?string
|
||||
{
|
||||
$country = trim((string) $country);
|
||||
if ($country === '') {
|
||||
return null;
|
||||
}
|
||||
$countries = \App\Data\Countries::all();
|
||||
if (preg_match('/^[A-Za-z]{2}$/', $country) && isset($countries[strtoupper($country)])) {
|
||||
return strtolower($country);
|
||||
}
|
||||
foreach ($countries as $iso2 => $meta) {
|
||||
if (strcasecmp($meta['name'], $country) === 0) {
|
||||
return strtolower($iso2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
+119
-4
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'notification_preferences' => 'array',
|
||||
// TAKEONE account link. The token is a live credential for that
|
||||
// platform, so it is encrypted at rest and never sent back to a browser.
|
||||
'takeone_token' => 'encrypted',
|
||||
'takeone_connected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Can this user run TAKEONE lookups?
|
||||
*
|
||||
* A super admin always can: the platform holds a TAKEONE super-admin token
|
||||
* on their behalf, so they are connected with full reach without ever
|
||||
* pasting anything. Everyone else links their own account, and sees only
|
||||
* what that account may see.
|
||||
*/
|
||||
public function takeoneLinked(): bool
|
||||
{
|
||||
return $this->takeoneToken() !== null;
|
||||
}
|
||||
|
||||
/** True when the connection is the platform's, not one this user pasted. */
|
||||
public function takeoneManagedLink(): bool
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
return ($own === null || $own === '')
|
||||
&& $this->isSuperAdmin()
|
||||
&& ! empty(config('services.takeone.admin_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to talk to TAKEONE with. A personally linked account always
|
||||
* wins, so a super admin who deliberately links their own identity is not
|
||||
* silently upgraded back to the platform credential.
|
||||
*/
|
||||
public function takeoneToken(): ?string
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
if ($own !== null && $own !== '') {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if ($this->isSuperAdmin()) {
|
||||
$platform = config('services.takeone.admin_token');
|
||||
|
||||
return ! empty($platform) ? $platform : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defaults for each preference key (true = on, false = off)
|
||||
private const NOTIF_DEFAULTS = [
|
||||
'notif_new_comment' => true,
|
||||
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-frequent ISO2 country for each user id, aggregating
|
||||
* video_views + profile_visits and falling back to users.nationality.
|
||||
* Returns [user_id => 'BH', …]; keys without data are omitted.
|
||||
*/
|
||||
public static function topCountriesFor(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
|
||||
if (! $userIds) return [];
|
||||
|
||||
$tally = [];
|
||||
$rows = DB::table('video_views')
|
||||
->selectRaw('user_id, country, COUNT(*) as c')
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
if (Schema::hasTable('profile_visits')) {
|
||||
$rows = DB::table('profile_visits')
|
||||
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
|
||||
->whereIn('visitor_user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('visitor_user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tally as $uid => $counts) {
|
||||
arsort($counts);
|
||||
$out[$uid] = array_key_first($counts);
|
||||
}
|
||||
|
||||
// Fallback to users.nationality for anyone still unresolved.
|
||||
$missing = array_values(array_diff($userIds, array_keys($out)));
|
||||
if ($missing) {
|
||||
$nats = static::whereIn('id', $missing)
|
||||
->whereNotNull('nationality')->where('nationality', '!=', '')
|
||||
->pluck('nationality', 'id');
|
||||
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
@@ -162,23 +262,38 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
public function getAvatarUrlAttribute(): string
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return route('media.avatar', $this->avatar);
|
||||
// ?v busts the browser cache when the avatar is replaced. The path is
|
||||
// always .../profile/avatar.<ext>, so without a version every page would
|
||||
// keep showing the previously cached image after an upload.
|
||||
return route('media.avatar', $this->avatar).'?v='.($this->updated_at?->timestamp ?? '0');
|
||||
}
|
||||
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
return asset('images/default-avatar.svg');
|
||||
}
|
||||
|
||||
public function getBannerUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->banner) {
|
||||
return route('media.banner', $this->banner);
|
||||
return route('media.banner', $this->banner).'?v='.($this->updated_at?->timestamp ?? '0');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new VerifyEmail);
|
||||
// Mail failures must never bubble up as a 500 to the user — the account
|
||||
// exists, the HTTP request was fine, only the delivery pipe is broken.
|
||||
// We log the failure (ops picks it up) and move on. Re-sending via
|
||||
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
|
||||
try {
|
||||
$this->notify(new VerifyEmail);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
|
||||
'user_id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
|
||||
+85
-2
@@ -53,6 +53,11 @@ class Video extends Model
|
||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function dislikes()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')
|
||||
@@ -67,7 +72,9 @@ class Video extends Model
|
||||
|
||||
public function audioTracks()
|
||||
{
|
||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
|
||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)
|
||||
->orderBy('position')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function hasSlideshow(): bool
|
||||
@@ -75,6 +82,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<int,\App\Models\VideoSlide>
|
||||
*/
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -141,6 +182,20 @@ class Video extends Model
|
||||
return $this->likes()->count();
|
||||
}
|
||||
|
||||
// Dislike helpers — same shape as the like pair above.
|
||||
public function isDislikedBy($user)
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
return $this->dislikes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function getDislikeCountAttribute()
|
||||
{
|
||||
return $this->dislikes()->count();
|
||||
}
|
||||
|
||||
// Get view count - use direct query to avoid timestamp issues
|
||||
public function getViewCountAttribute()
|
||||
{
|
||||
@@ -218,7 +273,29 @@ class Video extends Model
|
||||
if ($realId === null || $realId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->where('id', $realId)->firstOrFail();
|
||||
$video = $this->where('id', $realId)->first();
|
||||
if ($video) {
|
||||
return $video;
|
||||
}
|
||||
|
||||
// Video not found — check the merge-redirect table so old links still work.
|
||||
$redirect = \DB::table('video_redirects')->where('source_id', $realId)->first();
|
||||
if ($redirect) {
|
||||
$target = static::find($redirect->target_id);
|
||||
if ($target) {
|
||||
$newValue = self::encodeId($target->id);
|
||||
$newUrl = preg_replace(
|
||||
'#/'.preg_quote((string) $value, '#').'(?=/|\?|$)#',
|
||||
'/'.$newValue,
|
||||
request()->fullUrl(),
|
||||
1
|
||||
);
|
||||
throw new \Illuminate\Http\Exceptions\HttpResponseException(
|
||||
redirect()->to($newUrl, 302)
|
||||
);
|
||||
}
|
||||
}
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// All share URLs use the unguessable token route
|
||||
@@ -400,6 +477,12 @@ class Video extends Model
|
||||
return $this->hasMany(CoachReview::class)->orderBy('start_time_seconds');
|
||||
}
|
||||
|
||||
// The sports match record backing a type=match video (fighters, referee, etc.)
|
||||
public function sportsMatch()
|
||||
{
|
||||
return $this->hasOne(SportsMatch::class);
|
||||
}
|
||||
|
||||
// Get recent views count (within hours)
|
||||
public function getRecentViews($hours = 48)
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Fires to every super admin when the same email or IP has produced several
|
||||
* failed login attempts in a short window. Lets ops decide whether to lock
|
||||
* the account, change the password, or just raise the global throttle.
|
||||
*/
|
||||
class BruteForceLoginAlert extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $email,
|
||||
public readonly string $ip,
|
||||
public readonly int $failures,
|
||||
public readonly int $windowMinutes,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'kind' => 'brute_force_login_alert',
|
||||
'email' => $this->email,
|
||||
'ip' => $this->ip,
|
||||
'failures' => $this->failures,
|
||||
'window_minutes' => $this->windowMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('TAKEONE — suspicious login attempts')
|
||||
->greeting('Possible brute-force login attempt')
|
||||
->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.")
|
||||
->line("Most recent attempt came from {$this->ip}.")
|
||||
->line('Review the audit log and consider forcing a password reset if this account looks real.')
|
||||
->action('Open Admin', url('/admin/audit-logs'));
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
|
||||
);
|
||||
|
||||
// Force HTTPS
|
||||
URL::forceScheme('https');
|
||||
// Force HTTPS unless we're running under phpunit — the test client
|
||||
// speaks plain http://localhost; forcing https would make every POST
|
||||
// in a feature test resolve to a 302 instead of hitting the controller.
|
||||
if (! $this->app->environment('testing')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Blocks impersonation of staff/system accounts.
|
||||
*
|
||||
* Applied to both `name` and the email local-part at registration so a user
|
||||
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
|
||||
* let them pass themselves off as the platform or its operators to other
|
||||
* users (comments, DMs, channel pages).
|
||||
*/
|
||||
class ReservedIdentifier implements ValidationRule
|
||||
{
|
||||
private const RESERVED = [
|
||||
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
|
||||
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
|
||||
'moderator', 'mod', 'staff', 'official',
|
||||
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
|
||||
'security', 'abuse', 'billing',
|
||||
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
|
||||
'play', 'playbh', 'playtakeone',
|
||||
'anonymous', 'guest', 'null', 'undefined',
|
||||
];
|
||||
|
||||
public function __construct(private readonly string $kind = 'name')
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = $this->kind === 'email'
|
||||
? strtolower(trim(strstr($value, '@', true) ?: $value))
|
||||
: strtolower(trim($value));
|
||||
|
||||
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
|
||||
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
|
||||
|
||||
foreach (self::RESERVED as $word) {
|
||||
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
|
||||
if ($normalized === $target) {
|
||||
$fail($this->kind === 'email'
|
||||
? 'That email address is reserved. Please use a different one.'
|
||||
: 'That name is reserved. Please choose a different one.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Optional LLM helper for the lyrics pipeline.
|
||||
*
|
||||
* Supports multiple providers configured in Admin → Settings → AI / LLM
|
||||
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
|
||||
* Picks the provider flagged "Active" and dispatches the request through the
|
||||
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
|
||||
* the local model.
|
||||
*/
|
||||
class LlmLyricsService
|
||||
{
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return Setting::get('llm_enabled', 'false') === 'true'
|
||||
&& $this->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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate arbitrary text (e.g. a user comment) to a target language.
|
||||
* Returns the translated string, or '' on any failure / when disabled.
|
||||
*/
|
||||
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! $this->isEnabled()) return '';
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') return $cached;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
|
||||
. " - Translate EVERY word of the source language, including short exclamations,\n"
|
||||
. " repeated words, and words that happen to also look like a name in the target\n"
|
||||
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
|
||||
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
|
||||
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
|
||||
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
|
||||
. " it is almost certainly a regular word — translate it.\n"
|
||||
. " - If the text is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
|
||||
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
|
||||
. " - Keep the same tone and line breaks.\n\n"
|
||||
. "TEXT:\n" . $text;
|
||||
|
||||
// Translation is deterministic — temp 0. If the model echoes the input
|
||||
// (common when a word happens to also exist as a name in the target
|
||||
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
|
||||
// with a stronger directive. If the retry also echoes, we return it.
|
||||
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
|
||||
// keeps the model from drifting into long completions on tiny inputs.
|
||||
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
|
||||
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
|
||||
$needsRetry = $sourceLangHint
|
||||
&& $sourceLangHint !== $targetLang
|
||||
&& self::looksLikeEcho($text, $raw);
|
||||
if ($needsRetry) {
|
||||
$retryPrompt = $prompt
|
||||
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
|
||||
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
|
||||
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
|
||||
}
|
||||
if ($raw === '') return '';
|
||||
|
||||
Cache::put($cacheKey, $raw, now()->addDays(30));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function postProcess(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
|
||||
private static function looksLikeEcho(string $src, string $out): bool
|
||||
{
|
||||
if ($out === '') return false;
|
||||
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||
$a = $norm($src); $b = $norm($out);
|
||||
return $a !== '' && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a sequence of lines, preserving the one-to-one mapping so the
|
||||
* caller can render them side-by-side with the originals. Empty entries
|
||||
* are kept as empty strings. Returns an array of the same length as $lines
|
||||
* (or [] on any failure / when disabled).
|
||||
*/
|
||||
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
|
||||
{
|
||||
if (! $lines || ! $this->isEnabled()) return [];
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && count($cached) === count($lines)) return $cached;
|
||||
|
||||
// Index non-empty lines so empty separators are preserved verbatim.
|
||||
$toTranslate = [];
|
||||
foreach ($lines as $i => $l) {
|
||||
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
|
||||
}
|
||||
if (! $toTranslate) {
|
||||
Cache::put($cacheKey, $lines, now()->addDays(30));
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$numbered = [];
|
||||
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
|
||||
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
|
||||
. " - Translate every word including short exclamations and repeated words that happen\n"
|
||||
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
|
||||
. " - If a line is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
|
||||
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
|
||||
. "LINES (index TAB text):\n" . implode("\n", $numbered);
|
||||
|
||||
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
|
||||
$obj = json_decode($raw, true);
|
||||
if (! is_array($obj)) return [];
|
||||
|
||||
$out = $lines;
|
||||
$echoCount = 0;
|
||||
$translatedCount = 0;
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$out[$i] = $v;
|
||||
$translatedCount++;
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
|
||||
$echoCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If > 30% of the translated lines just echo the source, nudge the model.
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang
|
||||
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
|
||||
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
|
||||
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
|
||||
$obj2 = json_decode($raw2, true);
|
||||
if (is_array($obj2)) {
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
|
||||
$out[$i] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, $out, now()->addDays(30));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Dispatch to the active provider's adapter. */
|
||||
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$p = $this->activeProvider();
|
||||
if (! $p) return '';
|
||||
try {
|
||||
return match ($p['kind']) {
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
|
||||
// Qwen3-style "thinking" models respect /no_think to skip the hidden
|
||||
// reasoning phase. Without it they can exhaust num_predict inside
|
||||
// the thinking block and emit empty content.
|
||||
$taggedPrompt = $prompt . "\n\n/no_think";
|
||||
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||
'stream' => false,
|
||||
'think' => false,
|
||||
// Keep the model resident for an hour so follow-up requests skip
|
||||
// the cold-start reload (default is 5 minutes). Dramatically
|
||||
// reduces per-click latency for comment/description translation.
|
||||
'keep_alive' => '1h',
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
return '';
|
||||
}
|
||||
$j = $resp->json();
|
||||
$content = (string) ($j['message']['content'] ?? '');
|
||||
// Some thinking models still ignore the flag and leak <think>…</think>
|
||||
// tags into content. Strip them.
|
||||
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): 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,
|
||||
'temperature' => $temperature,
|
||||
'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, float $temperature = 0.2): 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' => $temperature,
|
||||
]);
|
||||
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'] ?? '');
|
||||
}
|
||||
}
|
||||
+274
-56
@@ -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);
|
||||
@@ -429,6 +490,12 @@ class NasSyncService
|
||||
{
|
||||
if (file_exists($localPath)) return true;
|
||||
|
||||
// Don't shell out to smbclient when we already know NAS is unreachable
|
||||
// — smbclient hangs ~30 sec on the SMB timeout, multiplied by every
|
||||
// thumbnail/avatar on the page. isEnabled() uses a 2-sec TCP probe
|
||||
// cached for 120 sec, so this fails fast during an outage.
|
||||
if (! $this->isEnabled()) return false;
|
||||
|
||||
$dir = dirname($localPath);
|
||||
if (! is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
|
||||
@@ -603,41 +670,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 +718,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 +782,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,
|
||||
@@ -933,6 +1021,7 @@ class NasSyncService
|
||||
|
||||
public function mkdirp(string $path): void
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -951,6 +1040,7 @@ class NasSyncService
|
||||
|
||||
public function putFile(string $localAbsPath, string $nasRelPath): bool
|
||||
{
|
||||
if (! $this->isEnabled()) return false; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -976,6 +1066,7 @@ class NasSyncService
|
||||
|
||||
public function getContent(string $nasRelPath): ?string
|
||||
{
|
||||
if (! $this->isEnabled()) return null; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -994,8 +1085,133 @@ 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
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -1019,6 +1235,7 @@ class NasSyncService
|
||||
|
||||
public function deleteFolder(string $nasRelPath): void
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -1117,8 +1334,9 @@ class NasSyncService
|
||||
/**
|
||||
* Rename a path on the NAS share (works for both files and directories).
|
||||
*/
|
||||
private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
|
||||
public function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VideoMergeService
|
||||
{
|
||||
public function __construct(private NasSyncService $nas) {}
|
||||
|
||||
/**
|
||||
* Preview counts for merging $source into $target. Read-only.
|
||||
*/
|
||||
public function preview(Video $source, Video $target): array
|
||||
{
|
||||
$tracks = DB::table('video_audio_tracks')->where('video_id', $source->id)->count();
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->count();
|
||||
$comments = DB::table('comments')->where('video_id', $source->id)->count();
|
||||
$views = DB::table('video_views')->where('video_id', $source->id)->count();
|
||||
$downloads = DB::table('video_downloads')->where('video_id', $source->id)->count();
|
||||
$shares = DB::table('video_shares')->where('video_id', $source->id)->count();
|
||||
|
||||
$srcLikeUsers = DB::table('video_likes')->where('video_id', $source->id)->pluck('user_id');
|
||||
$tgtLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->flip();
|
||||
$newLikes = $srcLikeUsers->reject(fn ($uid) => $tgtLikeUsers->has($uid))->count();
|
||||
|
||||
$srcPlaylists = DB::table('playlist_videos')->where('video_id', $source->id)->pluck('playlist_id');
|
||||
$tgtPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->flip();
|
||||
$plMerged = $srcPlaylists->reject(fn ($pid) => $tgtPlaylists->has($pid))->count();
|
||||
|
||||
return [
|
||||
'tracks' => $tracks + 1, // +1 = source's primary becomes a new track on target
|
||||
'slides' => $slides,
|
||||
'comments' => $comments,
|
||||
'views' => $views,
|
||||
'downloads' => $downloads,
|
||||
'shares' => $shares,
|
||||
'likes_source' => $srcLikeUsers->count(),
|
||||
'likes_after_dedupe' => $newLikes,
|
||||
'playlists_merged' => $plMerged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge $source video into $target. Both must be music-type.
|
||||
*
|
||||
* Simplified model: everything under sourceDir moves to targetDir, filename
|
||||
* prefixes are rewritten in one pass, and the user rearranges tracks/slides
|
||||
* in the edit UI afterwards. No special "primary vs extra" bookkeeping.
|
||||
*/
|
||||
public function merge(Video $source, Video $target): void
|
||||
{
|
||||
if ($source->id === $target->id) {
|
||||
throw new \InvalidArgumentException('Cannot merge a video with itself.');
|
||||
}
|
||||
if ($source->type !== 'music' || $target->type !== 'music') {
|
||||
throw new \InvalidArgumentException('Only music-type videos can be merged.');
|
||||
}
|
||||
$source->loadMissing(['audioTracks']);
|
||||
|
||||
$sourceDir = $this->nas->resolveVideoDir($source);
|
||||
$targetDir = $this->nas->resolveVideoDir($target);
|
||||
|
||||
if ($sourceDir === $targetDir) {
|
||||
throw new \RuntimeException('Source and target resolve to the same folder — refusing to merge.');
|
||||
}
|
||||
|
||||
// Merge is DB-first: the schema changes always run, and file moves
|
||||
// are best-effort on both sides. When NAS is unreachable, per-track
|
||||
// NAS renames get queued in `pending_nas_moves` and drained by
|
||||
// `nas:auto-sync` once NAS returns.
|
||||
$nasReachable = $this->nas->isEnabled();
|
||||
|
||||
Log::info('Merge: begin', [
|
||||
'source_id' => $source->id,
|
||||
'target_id' => $target->id,
|
||||
'source_dir' => $sourceDir,
|
||||
'target_dir' => $targetDir,
|
||||
'nas_reachable' => $nasReachable,
|
||||
]);
|
||||
|
||||
// Ensure target has a tracks/ subfolder — always locally; on NAS
|
||||
// only when it's reachable (mkdirp itself no-ops otherwise).
|
||||
$this->nas->mkdirp("{$targetDir}/tracks");
|
||||
@mkdir(storage_path("app/{$targetDir}/tracks"), 0755, true);
|
||||
|
||||
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||
|
||||
// ── 1. Promote source's primary audio to a new track on target ─
|
||||
// Layout-aware: the source may be stored either as the modern
|
||||
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
|
||||
// or as the legacy flat layout ({sourceDir}/audio.ext,
|
||||
// {sourceDir}/slides/, …). We detect which from source->path
|
||||
// and move only what actually exists in each case.
|
||||
$srcPrimaryPath = (string) $source->path;
|
||||
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||
|
||||
$newPrimaryTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
'language' => $source->language ?: 'xx',
|
||||
'label' => $source->title ?: null,
|
||||
'title' => $source->title ?: null,
|
||||
'description' => $source->description,
|
||||
'filename' => $srcPrimaryFilename,
|
||||
'path' => '',
|
||||
]);
|
||||
if (!$newPrimaryTrack || !$newPrimaryTrack->id) {
|
||||
throw new \RuntimeException('Failed to create promoted primary track record.');
|
||||
}
|
||||
|
||||
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
|
||||
// Case A: primary audio sits in a dedicated per-track folder → one
|
||||
// folder move brings the audio, its slides and its thumb along.
|
||||
// Case B: primary audio sits directly at the video root (legacy) →
|
||||
// move the individual files, since the video root also holds the
|
||||
// extra tracks' subfolders which stay put until step 2.
|
||||
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
|
||||
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
|
||||
} else {
|
||||
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
|
||||
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
|
||||
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
|
||||
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
|
||||
}
|
||||
// Opportunistically move thumb.{ext} if present.
|
||||
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
|
||||
$thumb = "{$sourceDir}/thumb.{$ext}";
|
||||
if (file_exists(storage_path('app/' . $thumb))) {
|
||||
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
|
||||
}
|
||||
}
|
||||
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
|
||||
// The new per-track layout stores lyrics at
|
||||
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
|
||||
// on the target resolves it immediately.
|
||||
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
|
||||
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
|
||||
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
|
||||
|
||||
// ── 2. Move each source extra track's folder into target/tracks/
|
||||
// The folder basename ({lang}-{track_id}) is globally unique
|
||||
// because track ids are globally unique — no collisions with
|
||||
// target's own tracks. We derive the actual old folder from
|
||||
// the track's stored path so unusual layouts still work.
|
||||
foreach ($source->audioTracks as $track) {
|
||||
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
if ($oldFolder !== $newFolder) {
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
}
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$track->save();
|
||||
|
||||
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
|
||||
// In the new layout they belong inside the moved track folder.
|
||||
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
|
||||
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
|
||||
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||
// location, derived from the track it belongs to AFTER the
|
||||
// per-track folder moves above. This is layout-agnostic —
|
||||
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$base = $s->filename ? basename($s->filename) : null;
|
||||
if (!$base) continue; // nothing we can safely rewrite
|
||||
|
||||
$owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
|
||||
$owningTrack = $updatedTracks->get($owningTrackId);
|
||||
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
|
||||
|
||||
$updates = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => "{$owningFolder}/slides/{$base}",
|
||||
];
|
||||
if ($s->audio_track_id === null) {
|
||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||
}
|
||||
DB::table('video_slides')->where('id', $s->id)->update($updates);
|
||||
}
|
||||
|
||||
// ── 4. Re-parent comments / views / downloads / shares ─────────
|
||||
DB::table('comments')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_views')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_downloads')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_shares')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 5. Likes — dedupe by user_id ────────────────────────────────
|
||||
$existingLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->all();
|
||||
if (!empty($existingLikeUsers)) {
|
||||
DB::table('video_likes')
|
||||
->where('video_id', $source->id)
|
||||
->whereIn('user_id', $existingLikeUsers)
|
||||
->delete();
|
||||
}
|
||||
DB::table('video_likes')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 6. Playlist pivots — dedupe by playlist_id ─────────────────
|
||||
$existingPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->all();
|
||||
if (!empty($existingPlaylists)) {
|
||||
DB::table('playlist_videos')
|
||||
->where('video_id', $source->id)
|
||||
->whereIn('playlist_id', $existingPlaylists)
|
||||
->delete();
|
||||
}
|
||||
DB::table('playlist_videos')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 7. Counters ────────────────────────────────────────────────
|
||||
DB::table('videos')->where('id', $target->id)->update([
|
||||
'download_count' => DB::raw('COALESCE(download_count,0) + ' . (int) $source->download_count),
|
||||
'share_count' => DB::raw('COALESCE(share_count,0) + ' . (int) $source->share_count),
|
||||
]);
|
||||
|
||||
// ── 8. Redirect row (remember source folder for later repair) ──
|
||||
DB::table('video_redirects')->insert([
|
||||
'source_id' => $source->id,
|
||||
'target_id' => $target->id,
|
||||
'source_folder' => $sourceDir,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// ── 9. Delete source video row ─────────────────────────────────
|
||||
DB::table('videos')->where('id', $source->id)->delete();
|
||||
});
|
||||
|
||||
// ── 10. Best-effort cleanup of the emptied source folder ───────────
|
||||
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
|
||||
// rmdir on sourceDir can succeed.
|
||||
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
|
||||
$this->pruneEmptyFolder("{$sourceDir}/tracks");
|
||||
$this->pruneEmptyFolder($sourceDir);
|
||||
|
||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an individual file (not a directory) on NAS + local. Needed for
|
||||
* legacy-layout sources where the primary audio and thumb live directly
|
||||
* in the video root rather than inside a per-track subfolder.
|
||||
*/
|
||||
private function moveFile(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->mkdirp(dirname($newRel));
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'file',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_file($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFile', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a folder on both NAS and local disk. NAS ops are queued in
|
||||
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
|
||||
*/
|
||||
private function moveFolder(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
// Queue for later — nas:auto-sync will do the rename when NAS is back.
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'folder',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_dir($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFolder', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
private function pruneEmptyFolder(string $rel): void
|
||||
{
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->deleteFile("{$rel}/meta.json");
|
||||
$this->nas->deleteFolder($rel);
|
||||
}
|
||||
// If NAS was unreachable we DON'T queue a delete: after auto-sync
|
||||
// drains the pending moves, the leftover source folder will be
|
||||
// empty and can be reaped by a subsequent housekeeping pass (or by
|
||||
// the videos:repair-merged command). Deleting a folder we haven't
|
||||
// yet emptied would blow away data.
|
||||
|
||||
$local = storage_path('app/' . $rel);
|
||||
if (is_dir($local)) {
|
||||
@unlink($local . '/meta.json');
|
||||
@rmdir($local);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,11 @@ class HtmlSanitizer
|
||||
/**
|
||||
* Produce safe display HTML. Legacy plain-text values (no tags) are
|
||||
* escaped and converted to <br>; rich values are run through clean().
|
||||
*
|
||||
* Also detects a handful of common markdown-ish prefixes that leaked
|
||||
* in through AI-generated descriptions ("## Letra", "### Chorus") and
|
||||
* turns them into real <h2>/<h3> tags before sanitising. Without this
|
||||
* step a reader saw literal "## Letra" instead of a heading.
|
||||
*/
|
||||
public static function render(?string $value): string
|
||||
{
|
||||
@@ -85,6 +90,8 @@ class HtmlSanitizer
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = self::promoteLeakedMarkdown($value);
|
||||
|
||||
if (strip_tags($value) === $value) {
|
||||
return nl2br(e($value), false);
|
||||
}
|
||||
@@ -92,6 +99,24 @@ class HtmlSanitizer
|
||||
return self::clean($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown-style heading prefixes at the start of a line into
|
||||
* real HTML tags, so the sanitiser can keep them. Only touches lines
|
||||
* that start with ##, ###, or **…**; everything else is untouched.
|
||||
*/
|
||||
private static function promoteLeakedMarkdown(string $value): string
|
||||
{
|
||||
$lines = preg_split("/\r?\n/", $value);
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
|
||||
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private static function sanitizeChildren(DOMNode $node): void
|
||||
{
|
||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
|
||||
* karaoke timing from a lyrics JSON payload (the shape produced by
|
||||
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
|
||||
*
|
||||
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
|
||||
* time it waits before the highlight reaches it, so the cumulative \k before a
|
||||
* word equals its onset relative to the line start. PrimaryColour is the sung
|
||||
* (filled) colour, SecondaryColour the not-yet-sung colour.
|
||||
*/
|
||||
class LyricsAss
|
||||
{
|
||||
/** Render at the same canvas the slideshow uses. */
|
||||
private const W = 1280;
|
||||
private const H = 720;
|
||||
|
||||
/**
|
||||
* Write an .ass file for the given lyrics data. Returns true on success,
|
||||
* false when there are no usable timed lines (caller should skip burning).
|
||||
*/
|
||||
public static function write(array $lyrics, string $outPath): bool
|
||||
{
|
||||
$lines = $lyrics['lines'] ?? [];
|
||||
if (! is_array($lines) || ! $lines) return false;
|
||||
|
||||
$events = [];
|
||||
foreach ($lines as $ln) {
|
||||
$start = $ln['start'] ?? null;
|
||||
$end = $ln['end'] ?? null;
|
||||
if ($start === null || $end === null) continue;
|
||||
|
||||
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
|
||||
$text = $words
|
||||
? self::karaokeText($words, (float) $start)
|
||||
: self::escape((string) ($ln['text'] ?? ''));
|
||||
if ($text === '') continue;
|
||||
|
||||
// Hold the line a touch past its last word for readability.
|
||||
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
|
||||
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
|
||||
. ',Lyrics,,0,0,,' . $text;
|
||||
}
|
||||
if (! $events) return false;
|
||||
|
||||
$ass = self::header() . implode("\n", $events) . "\n";
|
||||
return file_put_contents($outPath, $ass) !== false;
|
||||
}
|
||||
|
||||
private static function karaokeText(array $words, float $lineStart): string
|
||||
{
|
||||
$out = '';
|
||||
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
|
||||
if ($lead > 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Extract clean lyric lines from a video's free-text description.
|
||||
*
|
||||
* Many users paste the song's lyrics into the description with section markers,
|
||||
* emoji decorations, instrument tags, etc. When that's present we want to USE
|
||||
* those exact lines (and let the pipeline only do the *sync*, not the
|
||||
* transcription), because they're far more accurate than anything Whisper can
|
||||
* derive from sung audio.
|
||||
*
|
||||
* Returns an empty array when no usable lyric block is found.
|
||||
*/
|
||||
class LyricsDescriptionParser
|
||||
{
|
||||
/** Heuristic threshold: descriptions with fewer cleaned lines aren't worth aligning. */
|
||||
private const MIN_LYRIC_LINES = 4;
|
||||
|
||||
public static function extract(?string $desc): array
|
||||
{
|
||||
if (! $desc) return [];
|
||||
|
||||
// HTML descriptions use <br> 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 (<h1>…<h6>) 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
|
||||
* crash a user-facing request. Everywhere we previously did
|
||||
*
|
||||
* $user->notify(new SomeNotification(...));
|
||||
*
|
||||
* we now do
|
||||
*
|
||||
* SafeNotifier::send($user, new SomeNotification(...));
|
||||
*
|
||||
* which runs the same code, catches any Throwable, and writes a warning line
|
||||
* so ops still has the signal.
|
||||
*/
|
||||
class SafeNotifier
|
||||
{
|
||||
/**
|
||||
* @param mixed $notifiable Anything with ->notify(), usually a User.
|
||||
*/
|
||||
public static function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$notifiable->notify($notification);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('notification send failed', [
|
||||
'notification' => $notification::class,
|
||||
'notifiable' => $notifiable::class,
|
||||
'notifiable_id'=> $notifiable->getKey() ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
|
||||
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
|
||||
# Puppeteer can attach, surfacing as "Failed to launch the browser
|
||||
# process: Code: null". Bumping just this subprocess tree avoids
|
||||
# restarting FPM to raise the pool-wide limit.
|
||||
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"laravel/tinker": "^2.8",
|
||||
"p7h/nas-file-manager": "dev-main",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4",
|
||||
"pragmarx/google2fa-laravel": "^3.0"
|
||||
"pragmarx/google2fa-laravel": "^3.0",
|
||||
"spatie/browsershot": "^3.61"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
|
||||
Generated
+340
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||
"content-hash": "bdb64417cd2491642a0712fe40bade22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1457,6 +1457,90 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "2.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"guzzlehttp/psr7": "~1.1 || ^2.0",
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~0.9.2",
|
||||
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "to use GD library based image processing.",
|
||||
"ext-imagick": "to use Imagick based image processing.",
|
||||
"intervention/imagecache": "Caching extension for the Intervention Image library"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Image": "Intervention\\Image\\Facades\\Image"
|
||||
},
|
||||
"providers": [
|
||||
"Intervention\\Image\\ImageServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "2.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src/Intervention/Image"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Image handling and manipulation library with support for Laravel integration",
|
||||
"homepage": "http://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"laravel",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/2.7.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-05-21T17:30:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "10.50.2",
|
||||
@@ -2237,6 +2321,71 @@
|
||||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/glide",
|
||||
"version": "2.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/glide.git",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"intervention/image": "^2.7",
|
||||
"league/flysystem": "^2.0|^3.0",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/php-token-stream": "^3.1|^4.0",
|
||||
"phpunit/phpunit": "^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Glide\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jonathan Reinink",
|
||||
"email": "[email protected]",
|
||||
"homepage": "http://reinink.ca"
|
||||
},
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://titouangalopin.com"
|
||||
}
|
||||
],
|
||||
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
|
||||
"homepage": "http://glide.thephpleague.com",
|
||||
"keywords": [
|
||||
"ImageMagick",
|
||||
"editing",
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"league",
|
||||
"manipulation",
|
||||
"processing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/glide/issues",
|
||||
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
|
||||
},
|
||||
"time": "2025-03-21T13:48:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
@@ -4021,6 +4170,196 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "3.61.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^8.0",
|
||||
"spatie/image": "^1.5.3|^2.0",
|
||||
"spatie/temporary-directory": "^1.1|^2.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-21T10:00:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image",
|
||||
"version": "2.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image.git",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-exif": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"league/glide": "^2.2.2",
|
||||
"php": "^8.0",
|
||||
"spatie/image-optimizer": "^1.7",
|
||||
"spatie/temporary-directory": "^1.0|^2.0",
|
||||
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.22",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/var-dumper": "^4.0|^5.0|^6.0",
|
||||
"vimeo/psalm": "^4.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Manipulate images with an expressive API",
|
||||
"homepage": "https://github.com/spatie/image",
|
||||
"keywords": [
|
||||
"image",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/image/tree/2.2.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-24T13:54:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image-optimizer",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image-optimizer.git",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"php": "^7.4|^8.0",
|
||||
"psr/log": "^1.0 | ^2.0 | ^3.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
|
||||
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
|
||||
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\ImageOptimizer\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily optimize images using PHP",
|
||||
"homepage": "https://github.com/spatie/image-optimizer",
|
||||
"keywords": [
|
||||
"image-optimizer",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/image-optimizer/issues",
|
||||
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
|
||||
},
|
||||
"time": "2026-06-29T08:28:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Backup
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Settings for `php artisan takeone:backup`.
|
||||
|
|
||||
| The database and the media library are backed up by DIFFERENT mechanisms on
|
||||
| purpose. The database is small and must be captured as a consistent
|
||||
| point-in-time snapshot, so it is copied in full on every run. The media
|
||||
| library is gigabytes of immutable uploads, so it is mirrored incrementally
|
||||
| and only when asked — tarring it nightly would burn hours of CPU and fill
|
||||
| the disk with near-identical archives.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// Where snapshots are written. Lives under /data, which is git-ignored, so
|
||||
// a backup can never be committed by accident.
|
||||
'path' => env('BACKUP_PATH', base_path('data/backups')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Retention
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Artifacts older than retain_days are pruned, but the newest keep_minimum
|
||||
| are ALWAYS kept regardless of age — a box that sat idle for a month must
|
||||
| still have something to restore from.
|
||||
|
|
||||
*/
|
||||
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
|
||||
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Off-server copy
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A filesystems.php disk name. A snapshot sitting on the same disk as the
|
||||
| data survives a bad migration but not a dead disk, so set this to a
|
||||
| remote disk. The command warns on every run while it is unset.
|
||||
|
|
||||
*/
|
||||
'disk' => env('BACKUP_DISK'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media library
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
|
||||
| from anything. It is mirrored with rsync (incremental, resumable) to
|
||||
| media_destination, which should be a NAS mount or another machine.
|
||||
|
|
||||
| Only runs when --media is passed AND a destination is set.
|
||||
|
|
||||
| media_delete stays false by default: a mirror that deletes is one bad
|
||||
| path away from erasing the copy it was meant to protect.
|
||||
|
|
||||
*/
|
||||
'media_paths' => [
|
||||
base_path('data/app/users'),
|
||||
base_path('data/app/public'),
|
||||
],
|
||||
|
||||
'media_destination' => env('BACKUP_MEDIA_DEST'),
|
||||
|
||||
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Live streaming
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The media plane runs as MediaMTX on loopback; nginx proxies /live-rtc and
|
||||
| /live-hls to it. Nothing here is a secret except the TURN credential, which
|
||||
| comes from the environment.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// MediaMTX's control API (localhost only). Used to read live publisher and
|
||||
// viewer state.
|
||||
'api_url' => env('LIVE_API_URL', 'http://127.0.0.1:9997'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ICE
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| STUN lets a browser discover its public address. TURN relays the media
|
||||
| when a direct path cannot be established — which, until a UDP port is
|
||||
| forwarded to this server, is EVERY broadcaster outside the local network.
|
||||
|
|
||||
| With no TURN configured, publishing works from the LAN only.
|
||||
|
|
||||
*/
|
||||
'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'),
|
||||
|
||||
'turn_url' => env('LIVE_TURN_URL'),
|
||||
'turn_username' => env('LIVE_TURN_USERNAME'),
|
||||
'turn_credential' => env('LIVE_TURN_CREDENTIAL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Capture defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The target the phone is ASKED for. A camera that cannot do 1080p returns
|
||||
| the closest it can, and WebRTC's congestion control lowers the bitrate
|
||||
| when the uplink cannot sustain it — so this is a ceiling, not a promise.
|
||||
|
|
||||
*/
|
||||
'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920),
|
||||
'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080),
|
||||
'video_fps' => (int) env('LIVE_VIDEO_FPS', 30),
|
||||
|
||||
// ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink.
|
||||
'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_slides', function (Blueprint $table) {
|
||||
// NULL = song-wide (legacy / shared). Non-null = owned by that audio track.
|
||||
// On track delete, slides become song-wide rather than disappear.
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add playlist view tracking — mirrors the video_views pattern.
|
||||
*
|
||||
* • playlists.view_count — denormalised total for fast card rendering
|
||||
* • playlist_views table — one row per (playlist, viewer-id) so a refresh
|
||||
* within the dedup window doesn't double-count
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('playlists', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('video_redirects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('source_id')->unique();
|
||||
$table->foreignId('target_id')->constrained('videos')->cascadeOnDelete();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->index('target_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('video_redirects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->string('source_folder', 500)->nullable()->after('target_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->dropColumn('source_folder');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||
$table->unsignedInteger('position')->default(0)->after('label');
|
||||
$table->index(['video_id', 'position']);
|
||||
});
|
||||
|
||||
// Backfill existing rows so their current id-order becomes the
|
||||
// canonical position. New rows start at 0 and get placed at the
|
||||
// end by the update handler.
|
||||
DB::statement('UPDATE video_audio_tracks SET position = id');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||
$table->dropIndex(['video_id', 'position']);
|
||||
$table->dropColumn('position');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||
// Nullable — null means "play the primary track".
|
||||
$table->unsignedBigInteger('audio_track_id')
|
||||
->nullable()
|
||||
->after('video_id');
|
||||
|
||||
// Drop the old (playlist_id, video_id) uniqueness so the same
|
||||
// video can appear multiple times with different tracks.
|
||||
$table->dropUnique(['playlist_id', 'video_id']);
|
||||
|
||||
$table->index(['playlist_id', 'video_id', 'audio_track_id'], 'playlist_video_track_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||
$table->dropIndex('playlist_video_track_idx');
|
||||
$table->unique(['playlist_id', 'video_id']);
|
||||
$table->dropColumn('audio_track_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Records file/folder moves that were queued while NAS was
|
||||
// unreachable. `nas:auto-sync` drains rows when NAS is back:
|
||||
// for each row it renames from → to on NAS and deletes the row.
|
||||
Schema::create('pending_nas_moves', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('from_path', 500);
|
||||
$table->string('to_path', 500);
|
||||
$table->string('kind', 24)->default('folder'); // 'folder' or 'file'
|
||||
$table->string('origin', 40)->default('merge'); // where it came from
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->timestamp('last_attempt_at')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['origin', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pending_nas_moves');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add drag-and-dropped overlay position for coach notes. Stored as normalized
|
||||
* 0..1 coordinates of the overlay's CENTER within the video area so it
|
||||
* survives any viewport / fullscreen resize.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('position_x', 6, 4)->nullable()->after('emoji');
|
||||
$table->decimal('position_y', 6, 4)->nullable()->after('position_x');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['position_x', 'position_y']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Frame-accurate timestamps: the match highlights scrubber lands on
|
||||
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
|
||||
* throws away sub-second precision, so we promote the columns to
|
||||
* decimal(10,3) — 3 decimals cover up to 1000 fps and centuries of
|
||||
* runtime, and existing integer values migrate losslessly.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// MatchRound.start_time_seconds is used to jump to a round's opening
|
||||
// frame too, so bring it along.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->decimal('timestamp_seconds', 10, 3)->change();
|
||||
});
|
||||
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->change();
|
||||
$table->decimal('end_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Round back to integer on rollback — sub-second data would be
|
||||
// truncated but nothing catastrophic happens.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->nullable()->change();
|
||||
});
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->integer('timestamp_seconds')->change();
|
||||
});
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->change();
|
||||
$table->integer('end_time_seconds')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Live streaming.
|
||||
*
|
||||
* Purely additive: one new table. No existing table is altered — the link to a
|
||||
* recording is held here as video_id rather than adding a column to `videos`,
|
||||
* so the music and generic video paths run against exactly the schema they run
|
||||
* against today.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('livestreams', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Public, unpredictable identifier. Used in the watch URL AND as the
|
||||
// media path, so it must never be guessable or enumerable — knowing
|
||||
// one stream's id must not hint at another's.
|
||||
$table->string('public_id', 32)->unique();
|
||||
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
|
||||
// idle → live → ended, or failed.
|
||||
$table->string('status', 16)->default('idle');
|
||||
|
||||
// public = listed on the live index
|
||||
// unlisted = reachable only with the link
|
||||
$table->string('visibility', 16)->default('public');
|
||||
|
||||
// Short-lived, single-use credential the browser presents to the
|
||||
// media server when it starts publishing. Not a long-lived stream
|
||||
// key: it is minted on "Go live" and expires in minutes, so there
|
||||
// is no durable secret to leak, screenshot, or reuse.
|
||||
$table->string('publish_token', 64)->nullable();
|
||||
$table->timestamp('publish_token_expires_at')->nullable();
|
||||
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('ended_at')->nullable();
|
||||
|
||||
// Last time the media server confirmed the publisher is still
|
||||
// connected — how a stream that died without saying goodbye gets
|
||||
// reaped.
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
|
||||
$table->unsignedInteger('peak_viewers')->default(0);
|
||||
$table->unsignedInteger('current_viewers')->default(0);
|
||||
|
||||
// Set once a finished stream has been turned into a normal video.
|
||||
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// The live index reads "public streams that are live, newest first".
|
||||
$table->index(['status', 'visibility']);
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('livestreams');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Dislike counterpart to video_likes.
|
||||
*
|
||||
* One row per (user, video) pair. The unique index on the pair prevents a
|
||||
* user from racking up multiple dislikes against the same video, and the
|
||||
* foreign keys cascade so cleaning up a user or video drops their dislikes
|
||||
* for free.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('video_dislikes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['video_id', 'user_id']);
|
||||
$table->index('video_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('video_dislikes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Creator-engagement flags on comments.
|
||||
*
|
||||
* - hearted: the video owner has acknowledged the comment with a red
|
||||
* heart (YouTube's "creator heart"). Visible to everyone.
|
||||
* - pinned: the owner has anchored the comment to the top of the list.
|
||||
* The comment component sorts pinned comments ahead of everything
|
||||
* else, newest-pinned first.
|
||||
*
|
||||
* Both default to false and are indexed only on `pinned` because that's the
|
||||
* one the comments query sorts by.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->boolean('hearted')->default(false)->after('body');
|
||||
$table->boolean('pinned')->default(false)->after('hearted');
|
||||
$table->index('pinned');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->dropIndex(['pinned']);
|
||||
$table->dropColumn(['hearted', 'pinned']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Live chat messages — one row per message, scoped to a livestream. Simple
|
||||
* polling model: viewers hit /live/{public_id}/chat?since=<id> every few
|
||||
* seconds and get back everything newer than their last-seen id.
|
||||
*
|
||||
* Cascades: delete the livestream, lose its chat log. Users keep theirs
|
||||
* nulled out so a deleted account's historical messages still render as
|
||||
* "deleted user" rather than removing them from the thread.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('live_chat_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('livestream_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('author_name', 255); // snapshot of the sender's name at time of posting
|
||||
$table->text('body');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
// Chat pagination reads "messages in this stream newer than id X".
|
||||
$table->index(['livestream_id', 'id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('live_chat_messages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
# YouTube-parity audit remediation plan
|
||||
|
||||
Branch per phase. Feature tests per fix. STOP for approval between phases.
|
||||
|
||||
**Safety tag:** `backup/pre-audit-2026-10-21` at commit `5058eaf` on `full-screen-data`.
|
||||
**Stashed WIP:** `stash@{0}` — pre-audit WIP on `full-screen-data` (recover with `git stash pop` on that branch).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Security & data integrity
|
||||
Branch: `fix/phase-1-security`
|
||||
|
||||
- [ ] Wrap `sendEmailVerificationNotification` + `NewCommentNotification` mail in try/catch; move to a queued notification channel; log failures. Document `.env` for Gmail app password + Mailgun/Postmark fallback.
|
||||
- [ ] `php artisan app:resend-verification {--user=}` command.
|
||||
- [ ] Reserved-name blocklist (`admin`, `superadmin`, `takeone`, `support`, `root`, …) via `App\Rules\ReservedIdentifier` wired into `RegisteredUserController`.
|
||||
- [ ] Harden login throttling to `5,1` with lockout message; log repeated failures and alert super admins.
|
||||
- [ ] Confirm `email_verified_at` is not mass-assignable; only the signed-URL verification route flips it.
|
||||
- [ ] Comment dedupe: raise 5s → 60s window, add client idempotency key header, server reject on duplicate. Disable submit button on first click.
|
||||
- [ ] `php artisan comments:dedupe --dry-run` cleanup command.
|
||||
- [ ] Owner-view exclusion in `VideoController@trackProgress`; dedupe by `device_hash` within 30 min.
|
||||
- [ ] Feature tests for each item.
|
||||
|
||||
## Phase 2 — Performance & DOM
|
||||
Branch: `fix/phase-2-perf`
|
||||
|
||||
- [ ] One shared `#globalEditModal` + `#globalCardMenu` per page; populated via `GET /videos/{id}/edit-payload`. Owner-only items gated server-side.
|
||||
- [ ] Lazy-render reply/edit boxes (template clone on open).
|
||||
- [ ] Match highlights panel rendered once.
|
||||
- [ ] `href="javascript:void(0)"` → real `<button>` elements. Hide "Not interested" / "Don't recommend" / "Report" on own videos.
|
||||
- [ ] Cursor pagination (`?cursor=`) across Home, `/videos` filters, channel Videos, search, history, liked, comments.
|
||||
- [ ] IntersectionObserver infinite scroll + "Load more" fallback.
|
||||
- [ ] `loading="lazy"`, explicit `width`/`height`, skeleton placeholders on thumbnails.
|
||||
|
||||
## Phase 3 — Feeds, search, trending
|
||||
Branch: `fix/phase-3-feeds`
|
||||
|
||||
- [ ] Music + filter feeds switch to `latest()`.
|
||||
- [ ] Trending windows: 24 / 168 / 720 h; rank by in-window views.
|
||||
- [ ] Channel Popular: order by views, tiebreak by likes.
|
||||
- [ ] Duration badge on card thumbnail. Scoreboard thumb → `<img>` or `aria-hidden`. Strip `CLUBLOGO` / `FLAG` placeholders.
|
||||
- [ ] Filter tabs: fetch + `pushState`, sticky, `aria-current`.
|
||||
- [ ] Normalize naming (DB stays `match`, display label "Sports" everywhere).
|
||||
- [ ] "New to You" → "Recent" (or make personalized).
|
||||
- [ ] New `SearchController` with `/search?q=` matching title/description/tags/language/channel; include channels + playlists. `/videos/search` becomes 302 redirect. Keep query in input. Merge the two duplicate search forms.
|
||||
- [ ] `/feed/subscriptions` route backed by `subscriptions` table.
|
||||
|
||||
## Phase 4 — Watch page
|
||||
Branch: `fix/phase-4-watch`
|
||||
|
||||
- [ ] Up Next: real `<a href>` cards. Recommender by type/tags/language, cross-channel. Tabs All / From this channel / Same type. Show more.
|
||||
- [ ] Unified player wrapper for match/music/generic: theater (t), mini (i), PiP, volume slider, captions=lyrics (c), 2x speed, end screen, autoplay countdown. Autoplay-ON forces Loop-OFF.
|
||||
- [ ] Like count visible, dislike button, Subscribe for guests, viewer `⋯` → Report. Channel row on match videos.
|
||||
- [ ] `/videos/{id}/download/{format}` signed route; enforce per-video Downloads setting (Off/Everyone/Members/Subscribers) server-side for MP4 **and** MP3.
|
||||
- [ ] Comments: `?lc=` permalink with scroll + highlight. Heart + pin (creator). "Read more" clamp. @handle prefill on nested reply. Singular/plural counts. Top-level vs reply counts separate.
|
||||
- [ ] Markdown rendered (or stripped) in descriptions.
|
||||
- [ ] Dedupe audio Language menu.
|
||||
- [ ] Hide "Generating lyrics…" overlay unless job active.
|
||||
- [ ] Edit form prefilled with real values (fix "Generic" / "Loading…" defaults).
|
||||
- [ ] Streaming: HLS exists (`GenerateHlsJob`, `/videos/{video}/hls/{file}`). Switch player to hls.js when `hls_path` set. Verify `-movflags +faststart` on mp4 encode in `CompressVideoJob`. Add `Cache-Control` + Range on stream route.
|
||||
|
||||
## Phase 5 — Shorts
|
||||
Branch: `fix/phase-5-shorts`
|
||||
|
||||
- [ ] Upload flag + auto-detect (vertical, ≤60s).
|
||||
- [ ] `/shorts/{id}` route + branded 404 at `resources/views/errors/404.blade.php`.
|
||||
- [ ] `/shorts` feed: 100dvh snap container, single reused `<video>` moved between slots, next 1–2 preloaded, URL via `history.replaceState`, wheel + arrows + swipe + ▲▼. Loop. Muted autoplay + "Tap to unmute"; unmuted state in `localStorage`.
|
||||
- [ ] Right action column (like/dislike/comments/share). Bottom overlay (avatar/@handle/Subscribe/title). Comments side panel desktop / bottom sheet mobile.
|
||||
- [ ] Home Shorts shelf. Channel Shorts tab (fix "My Shorts").
|
||||
|
||||
## Phase 6 — Channel / Studio / Live
|
||||
Branch: `fix/phase-6-channel-studio-live`
|
||||
|
||||
- [ ] `users.handle` migration (unique, nullable → backfill → required). `/@handle` routes. Tabs `/@handle/{videos,shorts,playlists,posts,about}`. "Preview as visitor" toggle. Header shows description + links. Age/gender/horoscope private by default. Pluralize "1 video"; drop stray "· 0.0" on playlist cards.
|
||||
- [ ] `/studio/*`: content table + bulk edit, per-video analytics, two-step upload (Details → Visibility: tags + playlist + schedule), file-size + duration + processing-status surfaced.
|
||||
- [ ] Live: ended state → "Stream ended" + duration + recording (never "Waiting"). Live chat. Like / share / subscribe on viewer page. Channel name link. Private option on `/live/new` matching upload wording.
|
||||
|
||||
## Phase 7 — Admin, a11y, consistency
|
||||
Branch: `fix/phase-7-admin-a11y`
|
||||
|
||||
- [ ] Rename "Live Videos" → "Ready". Storage counts NAS (via `NasSyncService`). Fix KPI WoW %. Strip HTML in description excerpts. Full titles with tooltip. Next/Last pagination on Audit Logs, Users, Videos. Error log viewer groups by stack trace in natural order.
|
||||
- [ ] A11y: `aria-label` on every icon button (menu, search, voice, avatar, ⋯, Like-active, comment menus, seek slider with value text). Skip-to-content link. One `<h1>` per page. `label[for]` on all inputs. Remove duplicate "Prefer not to say". Birth year 13+ cap.
|
||||
- [ ] Title helper `{page} | Play`. Brand: **Play** displayed; `TAKEONE` stays as `config('app.name')` per CLAUDE.md.
|
||||
- [ ] Cleanup commands (all `--dry-run` first): `comments:dedupe`, `live:cleanup-test`, `tracks:dedupe`.
|
||||
|
||||
## Final deliverables
|
||||
- Checklist: done / partial / skipped (with reason).
|
||||
- `.env` keys the user must set.
|
||||
- Deploy steps: migrations, queue restart, cache clear, asset build.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Mail setup
|
||||
|
||||
Mail failures are now non-fatal — a down SMTP will log a warning in
|
||||
`data/logs/laravel.log` but will NOT 500 the user. Still, delivery is a
|
||||
feature; here's how to fix the current Gmail 535 auth errors.
|
||||
|
||||
## Option A — Gmail App Password (quickest, free, ~500 sends/day)
|
||||
|
||||
1. On `[email protected]`:
|
||||
- Enable 2-Step Verification (Google Account → Security).
|
||||
- Generate an **App Password** (Security → App passwords → "Mail", "Other: TAKEONE").
|
||||
- Copy the 16-character password Google shows (no spaces).
|
||||
|
||||
2. `.env`:
|
||||
```env
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=platformtakeone@gmail.com
|
||||
MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # the app password, no quotes needed
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=platformtakeone@gmail.com
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
3. `php artisan config:clear && php artisan queue:restart`
|
||||
|
||||
4. Smoke test:
|
||||
```bash
|
||||
php artisan app:resend-verification --user=<id-of-an-unverified-user>
|
||||
```
|
||||
|
||||
## Option B — Mailgun (recommended for production)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=mailgun
|
||||
MAILGUN_DOMAIN=mg.takeone.bh
|
||||
MAILGUN_SECRET=key-...
|
||||
MAILGUN_ENDPOINT=api.eu.mailgun.net # or api.mailgun.net for US region
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Option C — Postmark (also fine)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=postmark
|
||||
POSTMARK_TOKEN=...
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Resend verification to users whose first email failed
|
||||
|
||||
```bash
|
||||
# One specific user
|
||||
php artisan app:resend-verification --user=42
|
||||
|
||||
# Everyone still unverified (dry-run first)
|
||||
php artisan app:resend-verification --all-unverified --dry-run
|
||||
php artisan app:resend-verification --all-unverified
|
||||
```
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"634cd639-9aa5-4e99-93cf-a35ae77b2724":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"8f819462-17b1-46f6-8aa3-2de374e257f7"},{"id":"flagBr","uuid":"cc3aaea8-173f-4338-b7fa-2bae75a84d2b"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"dc229245-056d-4703-a7d0-641172e38b84"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"96676beb-9588-43e1-aef2-459578e9a205"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"efd1862c-8a2a-4b74-9741-9eb4a7b79af6\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"786f769a-eb68-482a-90e4-5f4c6341055c\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e50aced0-6710-4914-b239-a64494fce97d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"66dcdbee-b198-4b92-90c2-93c3b6f9111b\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"0be5c901-33ac-4507-bb55-5dafed4159e7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"2f8f7fe2-ce99-4a10-b583-ecf8c782a45d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"754fb47d-ebf2-4909-b5a0-f9abb2ed5c4d\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"7b059d00-08e1-424c-9715-c5b65f9a9411\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"faa370e3-1d36-48e0-b7c4-1a760ef73c77\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"dcfb04b8-57f1-4948-b81e-dcad7d26a879\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"eb64d5f4-973e-4865-8cd1-89be941b16be\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"11b56c5f-a515-42d7-bf11-6aa8bed5ecf6\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"3cf32598-e7d4-4db2-8d36-ceee8f725195\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"ace74a53-87ed-4d99-ad8b-261f3f162016":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"00166f10-cf88-4a70-bbd1-280330a31a6b"},{"id":"flagBr","uuid":"10064871-2703-4007-829b-9fe2ea8c9830"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"02eff75b-1dc5-4564-a819-548848bc3ea6"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"13d22153-cc4e-43c7-b6eb-12374e56dc44"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"290e25e2-2bac-49af-b3d1-10cdd1ae8fbb\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e49d5281-88ab-4404-9f97-f101a278fd68\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"41d613c6-fe3e-4f74-a5ee-c82165b70e04\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"73180845-6d9d-47f1-b736-31e7c919b715\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"024f77f2-3778-4e20-945a-dfc5de912f19\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"20b18051-9da7-42dd-a265-bdcef03ef454\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"a141991d-2d31-4e13-9ba3-a7e1903cb3a4\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"d996ac61-9dd4-4628-92ec-575a6a8ebab7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"4c129e83-dcd6-40f3-9b2f-75eb52c2570f\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"b0420d9b-b5d9-4e9b-a506-be5ec972d317\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"643b5739-aeb8-4056-81ee-78f1669802f5\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"1c70acd1-9588-4697-b346-56c7675f7674\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"fc1721cb-bce6-4bcc-8311-e4be3be85090\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Score bar — exact markup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@700&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;background:#111;font-family:'Barlow Condensed',sans-serif}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--
|
||||
SCORE BAR — copy this markup verbatim. Do not restructure it.
|
||||
Placement: inside the player container, which must be position:relative.
|
||||
Data holes are marked with {{ }} — replace ONLY those, nothing else.
|
||||
Hide a part by setting its element's display to none (never remove the element's siblings' flex sizing).
|
||||
|
||||
Rules that break the design if changed:
|
||||
- the panel has transform:skewX(-9deg); ONE inner row has transform:skewX(9deg). Never counter-skew individual children.
|
||||
- every text span keeps flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis
|
||||
- the logo (46px) and score (60px) boxes keep flex:none
|
||||
- bottom:56px keeps the bar above the 48px control band
|
||||
-->
|
||||
|
||||
<div style="position:relative;width:1150px;height:200px;background:#1b1b1e">
|
||||
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
|
||||
|
||||
<!-- RED / AKA -->
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redName }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redClub }}</span>
|
||||
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">{{ redLabel }}</span>
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ redScore }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: round + clock -->
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">{{ roundLabel }}</div>
|
||||
<div style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">{{ clock }}</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLUE / AO — exact mirror -->
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">{{ blueLabel }}</span>
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ blueScore }}</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueName }}</span>
|
||||
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty -->
|
||||
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||
Generated
+2537
File diff suppressed because it is too large.
Load diff
@@ -9,5 +9,8 @@
|
||||
"axios": "^1.6.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.43.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coach Review — Design Mockups</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--bg-panel: #181818;
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaa;
|
||||
--brand-red: #e61e1e;
|
||||
--border: #262626;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
@media (max-width: 900px) { .page { grid-template-columns: 1fr; } }
|
||||
|
||||
.mockup {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.mockup h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mockup h2 .letter {
|
||||
display: inline-block;
|
||||
background: var(--brand-red);
|
||||
color: #fff;
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.mockup .vibe { font-size: 12px; color: var(--text-secondary); margin: 0 0 18px; }
|
||||
.mockup .stage {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #202020;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
min-height: 260px;
|
||||
}
|
||||
.mockup .stage > * + * { margin-top: 8px; }
|
||||
|
||||
.pill { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
|
||||
|
||||
/* ─── Option A: Sticky-note ─────────────────────────── */
|
||||
.a-card {
|
||||
display: flex; gap: 14px;
|
||||
background: rgba(234, 179, 8, .05);
|
||||
border: 1px solid rgba(234, 179, 8, .18);
|
||||
border-left: 4px solid rgba(234, 179, 8, .7);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.a-card .emoji { font-size: 26px; line-height: 1; flex-shrink: 0; }
|
||||
.a-card .body { flex: 1; min-width: 0; }
|
||||
.a-card .note { font-size: 15px; font-weight: 600; color: #f1f1f1; margin: 0 0 6px; }
|
||||
.a-card .divider { height: 1px; background: rgba(255,255,255,.08); margin: 8px 0; }
|
||||
.a-card .meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.a-card .meta-left { font-size: 12px; color: var(--text-secondary); }
|
||||
.a-card .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.a-card .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.a-card .tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option B: Timeline dot ────────────────────────── */
|
||||
.b-timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
}
|
||||
.b-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px; top: 0; bottom: 0;
|
||||
width: 2px; background: rgba(255,255,255,.08);
|
||||
}
|
||||
.b-item { position: relative; margin-bottom: 22px; }
|
||||
.b-item:last-child { margin-bottom: 0; }
|
||||
.b-dot {
|
||||
position: absolute; left: -26px; top: 2px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--brand-red);
|
||||
box-shadow: 0 0 0 3px rgba(230,30,30,.2), 0 0 0 6px var(--bg-panel);
|
||||
}
|
||||
.b-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
color: var(--brand-red); text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.b-card {
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
}
|
||||
.b-card .emoji { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
|
||||
.b-card .body { flex: 1; min-width: 0; }
|
||||
.b-card .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 4px; }
|
||||
.b-card .author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.b-card .tools { display: inline-flex; gap: 4px; align-self: center; }
|
||||
|
||||
/* ─── Option C: Media card ──────────────────────────── */
|
||||
.c-card {
|
||||
display: flex; gap: 14px;
|
||||
background: #141414;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.c-thumb {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(230,30,30,.28), rgba(230,30,30,.08));
|
||||
border: 1px solid rgba(230,30,30,.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
.c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .04em;
|
||||
color: #3ea6ff;
|
||||
background: rgba(62, 166, 255, .12);
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.c-note { font-size: 14px; font-weight: 600; color: #f1f1f1; margin: 4px 0; line-height: 1.3; }
|
||||
.c-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.c-author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.c-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option D: Chat-bubble ─────────────────────────── */
|
||||
.d-row { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.d-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border: 2px solid var(--brand-red);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; line-height: 1;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
.d-bubble {
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
padding: 10px 14px;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.d-bubble .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 6px; line-height: 1.35; }
|
||||
.d-bubble .meta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding-top: 6px; border-top: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.d-bubble .meta-left { font-size: 11px; color: var(--text-secondary); }
|
||||
.d-bubble .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.d-bubble .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.d-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Shared button styling for mockups ─────────────── */
|
||||
.btn-icon {
|
||||
width: 26px; height: 26px; padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 5px; border: none;
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text-primary); cursor: pointer;
|
||||
font-size: 12px; line-height: 1;
|
||||
transition: background .12s;
|
||||
}
|
||||
.btn-icon:hover { background: rgba(255,255,255,.14); }
|
||||
.btn-slowmo {
|
||||
width: auto; padding: 0 10px; gap: 4px;
|
||||
background: rgba(230,30,30,.16); color: var(--brand-red);
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn-slowmo:hover { background: rgba(230,30,30,.28); color: #fff; }
|
||||
|
||||
/* header */
|
||||
.header {
|
||||
text-align: center; margin-bottom: 32px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Coach Review Card — 4 design directions</h1>
|
||||
<p>Same content, four different visual approaches. Pick one and I'll build it.</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ═══ Option A: Sticky-note ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">A</span> Sticky-note</h2>
|
||||
<p class="vibe">Warm amber tint, chunky emoji, feels like a coach's Post-it on a locker.</p>
|
||||
<div class="stage">
|
||||
<div class="a-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Ghassan</b><span class="dot">·</span>00:48 – 00:52
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter opportunity — great angle but no follow-up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Coach Sara</b><span class="dot">·</span>02:05 – 02:20
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option B: Timeline dot ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">B</span> Timeline dot</h2>
|
||||
<p class="vibe">Vertical spine with red dots. Reads like a session log — nice for many notes.</p>
|
||||
<div class="stage">
|
||||
<div class="b-timeline">
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">00:48 — 00:52</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">02:05 — 02:20</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="author">Coach Sara</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">03:25</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">😄</span>
|
||||
<div class="body">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option C: Media card ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">C</span> Media card</h2>
|
||||
<p class="vibe">Big emoji tile on the left, blue time badge — feels like a video-platform "content card".</p>
|
||||
<div class="stage">
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">👀</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">00:48 → 00:52</span></div>
|
||||
<p class="c-note">Keep Your Hand Up</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Ghassan</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">🤔</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">02:05 → 02:20</span></div>
|
||||
<p class="c-note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Coach Sara</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option D: Chat-bubble ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">D</span> Chat-bubble</h2>
|
||||
<p class="vibe">Emoji as coach's avatar + message bubble. Feels like WhatsApp — casual + dense.</p>
|
||||
<div class="stage">
|
||||
<div class="d-row">
|
||||
<div class="d-avatar">👀</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>00:48–00:52</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">🤔</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Coach Sara</b><span class="dot">·</span>02:05–02:20</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">😄</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>03:25</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150" width="150" height="150">
|
||||
<rect width="150" height="150" fill="#2a2a2a"/>
|
||||
<circle cx="75" cy="60" r="26" fill="#6a6a6a"/>
|
||||
<path d="M25,150 C25,110 50,92 75,92 C100,92 125,110 125,150 Z" fill="#6a6a6a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@@ -351,24 +351,40 @@
|
||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||
</div>
|
||||
<div class="al-page-links">
|
||||
{{-- First --}}
|
||||
@if($logs->onFirstPage())
|
||||
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
|
||||
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
|
||||
@else
|
||||
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
|
||||
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
|
||||
@endif
|
||||
|
||||
{{-- Prev --}}
|
||||
@if($logs->onFirstPage())
|
||||
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
|
||||
@else
|
||||
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
|
||||
@endif
|
||||
|
||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||
@if($page == $logs->currentPage())
|
||||
<span class="active">{{ $page }}</span>
|
||||
<span class="active" aria-current="page">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next --}}
|
||||
@if($logs->hasMorePages())
|
||||
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
|
||||
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
|
||||
@else
|
||||
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
|
||||
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
|
||||
@endif
|
||||
|
||||
{{-- Last --}}
|
||||
@if($logs->currentPage() < $logs->lastPage())
|
||||
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
|
||||
@else
|
||||
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Backup & Restore')
|
||||
@section('page_title', 'Backup & Restore')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-archive"></i> Backup & Restore</h1>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->any())
|
||||
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span>{{ $errors->first() }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-archive"></i>
|
||||
Users & Settings Backup
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Export users & settings</strong>
|
||||
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-download"></i> Download Backup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Restore users & settings</strong>
|
||||
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
|
||||
@csrf
|
||||
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none"
|
||||
onchange="document.getElementById('restoreForm').submit()">
|
||||
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
|
||||
<i class="bi bi-upload"></i> Upload & Restore
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -292,13 +292,16 @@
|
||||
|
||||
{{-- Ready Videos --}}
|
||||
<div class="col-6 col-lg-4 col-xl-2">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
|
||||
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
|
||||
<div class="stat-label">Live Videos</div>
|
||||
{{-- "Ready" matches the DB status column; "Live Videos" was misleading
|
||||
— nothing streams live from this card, it just counts videos that
|
||||
finished processing successfully. --}}
|
||||
<div class="stat-label">Ready</div>
|
||||
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
||||
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
||||
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
||||
@@ -479,10 +482,10 @@
|
||||
@else
|
||||
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
||||
@foreach($viewsByCountry as $i => $row)
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-rank">{{ $i + 1 }}</div>
|
||||
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
||||
<div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||
</div>
|
||||
@@ -741,8 +744,21 @@ new Chart(document.getElementById('typeChart'), {
|
||||
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
@php
|
||||
// Blade's @json() parser can't handle a multi-line arrow function with
|
||||
// a `[` array literal inside its body — the directive reads until the
|
||||
// closing paren and sees the inner `[` as an unmatched bracket. Build
|
||||
// the array in PHP first, then hand the plain variable to @json().
|
||||
$countryChartData = $viewsByCountry->map(function ($r) {
|
||||
return [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
'total' => $r->total,
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
(function() {
|
||||
const countryData = @json($viewsByCountry);
|
||||
const countryData = @json($countryChartData);
|
||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||
const values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
@@ -1015,7 +1031,10 @@ if (typeChartInst) {
|
||||
// Country bar chart click
|
||||
const countryChartInst = Chart.getChart('countryChart');
|
||||
if (countryChartInst) {
|
||||
const countryData = @json($viewsByCountry ?? collect());
|
||||
const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
])->values());
|
||||
countryChartInst.options.onClick = function(evt, elements) {
|
||||
if (!elements.length) return;
|
||||
const row = countryData[elements[0].index];
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'GPU Accelerator')
|
||||
@section('page_title', 'GPU Accelerator')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-gpu-card"></i> GPU Accelerator</h1>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->any())
|
||||
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span>{{ $errors->first() }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.settings.update') }}" id="gpuForm">
|
||||
@csrf
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-gpu-card"></i>
|
||||
GPU Accelerated Processing
|
||||
<span id="gpuStatusChip" style="margin-left:6px;">
|
||||
@if(count($gpus))
|
||||
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
|
||||
@else
|
||||
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
|
||||
@endif
|
||||
</span>
|
||||
<span id="nvencStatusChip" style="margin-left:6px;">
|
||||
@if($nvencWorks)
|
||||
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
|
||||
@else
|
||||
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(!$nvencWorks && count($gpus))
|
||||
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
|
||||
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
|
||||
The GPU is detected but FFmpeg cannot initialise CUDA on this driver.<br>
|
||||
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
|
||||
Until then, video encoding will automatically fall back to CPU (libx264).
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||||
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
|
||||
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
|
||||
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
|
||||
</button>
|
||||
</div>
|
||||
<div id="gpuCardsWrap">
|
||||
@if(count($gpus))
|
||||
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
|
||||
@else
|
||||
<div class="no-gpu-state">
|
||||
<i class="bi bi-gpu-card"></i>
|
||||
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Enable GPU acceleration</strong>
|
||||
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
|
||||
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label" id="gpuEnabledLabel">
|
||||
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</label>
|
||||
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
|
||||
value="{{ $settings['gpu_enabled'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
|
||||
|
||||
<div class="setting-row" id="gpuEncoderRow">
|
||||
<div class="setting-label">
|
||||
<strong>Video encoder</strong>
|
||||
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="enc-grid">
|
||||
@foreach([
|
||||
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
|
||||
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
|
||||
['libx264', 'libx264', 'CPU · software fallback'],
|
||||
] as [$val, $label, $desc])
|
||||
<button type="button"
|
||||
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
|
||||
data-encoder="{{ $val }}"
|
||||
onclick="selectEncoder(this)">
|
||||
<span class="enc-card-name">{{ $label }}</span>
|
||||
<span class="enc-card-desc">{{ $desc }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="gpuPresetRow">
|
||||
<div class="setting-label">
|
||||
<strong>Encoding preset</strong>
|
||||
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
|
||||
<optgroup label="NVENC (GPU)">
|
||||
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
|
||||
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
||||
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="libx264 (CPU)">
|
||||
@foreach(['fast','medium','slow'] as $p)
|
||||
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
||||
{{ $p }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" id="gpuHwaccelRow">
|
||||
<div class="setting-label">
|
||||
<strong>Hardware decode acceleration</strong>
|
||||
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select name="gpu_hwaccel" class="adm-select-full">
|
||||
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
|
||||
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>FFmpeg binary path</strong>
|
||||
<small>
|
||||
Absolute path to the <code>ffmpeg</code> executable.
|
||||
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
|
||||
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" name="ffmpeg_binary" class="adm-input-full"
|
||||
value="{{ $settings['ffmpeg_binary'] }}"
|
||||
placeholder="/usr/bin/ffmpeg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
|
||||
<button type="submit" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-floppy"></i> Save GPU Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
function selectGpuCard(el) {
|
||||
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
document.getElementById('gpuDeviceInput').value = el.dataset.index;
|
||||
}
|
||||
|
||||
function selectEncoder(el) {
|
||||
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
|
||||
const isCpu = el.dataset.encoder === 'libx264';
|
||||
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
||||
}
|
||||
|
||||
const gpuToggle = document.getElementById('gpuEnabledInput');
|
||||
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
||||
const gpuLabel = document.getElementById('gpuEnabledLabel');
|
||||
function applyGpuToggle() {
|
||||
const on = gpuToggle.checked;
|
||||
gpuHidden.value = on ? 'true' : 'false';
|
||||
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
gpuToggle.addEventListener('change', applyGpuToggle);
|
||||
|
||||
async function detectGpus() {
|
||||
const btn = document.getElementById('detectBtn');
|
||||
const icon = document.getElementById('detectIcon');
|
||||
btn.disabled = true;
|
||||
icon.className = 'bi bi-arrow-repeat spin';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
|
||||
const data = await res.json();
|
||||
const gpus = data.gpus || [];
|
||||
const wrap = document.getElementById('gpuCardsWrap');
|
||||
const chip = document.getElementById('gpuStatusChip');
|
||||
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
|
||||
|
||||
if (gpus.length === 0) {
|
||||
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
|
||||
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
|
||||
} else {
|
||||
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
|
||||
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-arrow-repeat';
|
||||
}
|
||||
|
||||
function buildGpuCards(gpus, selectedDevice) {
|
||||
if (!gpus.length) return '';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'gpu-grid';
|
||||
gpus.forEach(gpu => {
|
||||
const used = gpu.mem_total - gpu.mem_free;
|
||||
const usedPct = Math.round((used / gpu.mem_total) * 100);
|
||||
const sel = gpu.index === selectedDevice;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'gpu-card' + (sel ? ' selected' : '');
|
||||
card.dataset.index = gpu.index;
|
||||
card.onclick = function() { selectGpuCard(this); };
|
||||
card.innerHTML = `
|
||||
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
|
||||
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
|
||||
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
|
||||
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
|
||||
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
|
||||
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
|
||||
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
|
||||
<div class="mem-bar-wrap"><div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div></div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
return grid.outerHTML;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -538,7 +538,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="adm-user-avatar" alt="">
|
||||
@endif
|
||||
<div class="adm-user-info">
|
||||
<div class="adm-user-name">{{ Str::limit(Auth::user()->name, 18) }}</div>
|
||||
@@ -597,7 +597,23 @@
|
||||
<div class="adm-nav-section" style="margin-top:8px;">System</div>
|
||||
<a href="{{ route('admin.settings') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.settings*') ? 'active' : '' }}">
|
||||
<i class="bi bi-gpu-card"></i> Settings
|
||||
<i class="bi bi-stars"></i> AI / LLM
|
||||
</a>
|
||||
<a href="{{ route('admin.lyrics') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.lyrics') ? 'active' : '' }}">
|
||||
<i class="bi bi-music-note-list"></i> Lyrics Pipeline
|
||||
</a>
|
||||
<a href="{{ route('admin.gpu') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.gpu') ? 'active' : '' }}">
|
||||
<i class="bi bi-gpu-card"></i> GPU Accelerator
|
||||
</a>
|
||||
<a href="{{ route('admin.nas-storage') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
</a>
|
||||
<a href="{{ route('admin.backup') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.backup') ? 'active' : '' }}">
|
||||
<i class="bi bi-archive"></i> Backup & Restore
|
||||
</a>
|
||||
<a href="{{ route('admin.audit') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.audit') ? 'active' : '' }}">
|
||||
@@ -607,10 +623,6 @@
|
||||
class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}">
|
||||
<i class="bi bi-bug"></i> Error Logs
|
||||
</a>
|
||||
<a href="{{ route('admin.nas-storage') }}"
|
||||
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="adm-nav-divider"></div>
|
||||
@@ -625,7 +637,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" alt="">
|
||||
@endif
|
||||
<div class="adm-sidebar-user-info">
|
||||
<div class="adm-sidebar-user-name">{{ Str::limit(Auth::user()->name, 20) }}</div>
|
||||
|
||||
@@ -64,25 +64,73 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{-- Log lines --}}
|
||||
{{-- Log lines, grouped — each entry's stack trace (lines beginning with
|
||||
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
|
||||
header line. Headers show their level color; stack trace lines
|
||||
render in a muted indent and preserve their natural #0 → #N order
|
||||
inside each group even when the overall list is newest-first. --}}
|
||||
@if(empty($lines))
|
||||
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
||||
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
|
||||
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
|
||||
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
// Group the flat list of lines into [{header, trace[]}, …].
|
||||
// Any line that is NOT a header starts/continues the current
|
||||
// group's trace. Lines that look like a header (ERROR/WARNING/
|
||||
// INFO/DEBUG) start a new group.
|
||||
$groups = [];
|
||||
$current = null;
|
||||
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
|
||||
foreach ($lines as $line) {
|
||||
if ($isHeader($line) || $current === null) {
|
||||
if ($current) $groups[] = $current;
|
||||
$current = ['header' => $line, 'trace' => []];
|
||||
} else {
|
||||
$current['trace'][] = $line;
|
||||
}
|
||||
}
|
||||
if ($current) $groups[] = $current;
|
||||
@endphp
|
||||
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
||||
@foreach($lines as $line)
|
||||
@foreach($groups as $gi => $g)
|
||||
@php
|
||||
$color = '#aaa';
|
||||
if (str_contains($line, '.ERROR:')) $color = '#f87171';
|
||||
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
|
||||
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
||||
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
||||
$hasTrace = !empty($g['trace']);
|
||||
@endphp
|
||||
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
|
||||
<div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
|
||||
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
|
||||
@if($hasTrace)
|
||||
<button type="button"
|
||||
onclick="this.closest('.log-group').classList.toggle('open')"
|
||||
aria-label="Toggle stack trace"
|
||||
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
|
||||
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
@else
|
||||
<span style="flex:none;display:inline-block;width:20px;"></span>
|
||||
@endif
|
||||
<span>{{ $g['header'] }}</span>
|
||||
</div>
|
||||
@if($hasTrace)
|
||||
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
|
||||
@foreach($g['trace'] as $tl)
|
||||
<div>{{ $tl }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<style>
|
||||
.log-group.open .log-trace { display: block !important; }
|
||||
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Lyrics Pipeline')
|
||||
@section('page_title', 'Lyrics Pipeline')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title"><i class="bi bi-music-note-list"></i> Lyrics Pipeline</h1>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.settings.update') }}" id="lyricsForm">
|
||||
@csrf
|
||||
<input type="hidden" name="lyrics_section" value="1">
|
||||
|
||||
{{-- ── Master switch ─────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-toggle-on"></i>
|
||||
Master
|
||||
@if($settings['lyrics_enabled'] === 'true')
|
||||
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
|
||||
@else
|
||||
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Lyrics generation</strong>
|
||||
<small>
|
||||
Master switch for the whole feature. When OFF, the Generate / Regenerate
|
||||
button in the player gear is hidden and the API endpoint refuses new jobs.
|
||||
Existing lyrics keep displaying — only NEW generation is blocked.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_enabled" value="true" {{ $settings['lyrics_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Allow new lyrics jobs</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Pipeline steps ────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
Pipeline steps
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Description-based correction</strong>
|
||||
<small>
|
||||
Use the song description as the source of truth for lyric text. Whisper provides
|
||||
timing anchors; the description provides the exact words. When OFF, the player
|
||||
shows Whisper's raw transcription (may contain misheard words and miss verses).
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_use_description" value="true" {{ $settings['lyrics_use_description'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Use description as ground truth</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Voice activity filter (VAD)</strong>
|
||||
<small>
|
||||
Runs Silero VAD before Whisper to skip pure-instrumental sections. Without this,
|
||||
Whisper invents lyrics over intro / outro / breakdown music. When OFF, the entire
|
||||
audio is transcribed — useful as a fallback if VAD is dropping legitimate quiet vocals.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_vad_enabled" value="true" {{ $settings['lyrics_vad_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Drop instrumentals via VAD</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Vocal-region gap-filling</strong>
|
||||
<small>
|
||||
For description lines Whisper missed, snap them to moments where vocals are
|
||||
active (detected by VAD) instead of distributing evenly across time. Keeps
|
||||
gap-filled lyrics on actual singing. When OFF, missing lines distribute uniformly
|
||||
between Whisper anchors (faster but can drift over instrumental beats).
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_vocal_region_gapfill" value="true" {{ $settings['lyrics_vocal_region_gapfill'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Snap missing lines to vocal regions</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Vocal isolation (Demucs)</strong>
|
||||
<small>
|
||||
Two-stem source separation (vocals vs. instruments) before Whisper. Improves
|
||||
transcription on noisy mixes but adds ~30s GPU time and has caused a CUDA-handoff
|
||||
deadlock that hangs the pipeline at 50%. Currently auto-skipped when a description
|
||||
is present (timing-only mode). Enable to also use Demucs when no description is given.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_demucs_enabled" value="true" {{ $settings['lyrics_demucs_enabled'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Use Demucs when no description</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>LLM emoji decoration</strong>
|
||||
<small>
|
||||
After lyrics are saved, run the active LLM provider to bake one or more emojis
|
||||
into each line. Without this, the player still decorates lines using a built-in
|
||||
keyword-to-emoji map (less varied). Provider is configured under
|
||||
<a href="{{ route('admin.settings') }}" style="color:var(--brand)">AI / LLM Settings</a>.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" name="lyrics_llm_decorate" value="true" {{ $settings['lyrics_llm_decorate'] === 'true' ? 'checked' : '' }}>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">Bake emojis via LLM</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
|
||||
<button type="submit" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-floppy"></i> Save Lyrics Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@endsection
|
||||
@@ -1,23 +1,356 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'NAS Storage')
|
||||
@section('page_title', 'NAS Storage')
|
||||
|
||||
@section('extra_styles')
|
||||
@include('admin.partials.settings-styles')
|
||||
<style>
|
||||
.nas-repair-result { padding: 14px 0 0; font-size: 13px; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<span class="chip chip-green" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Enabled</span>
|
||||
@else
|
||||
<span class="chip chip-red" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Disabled</span>
|
||||
@endif
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
@include('nas-file-manager::file-manager', [
|
||||
'nodes' => $nodes,
|
||||
'canEdit' => true,
|
||||
'title' => 'NAS Storage Browser',
|
||||
])
|
||||
@if(session('success'))
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── NAS Settings ─────────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-sliders"></i>
|
||||
NAS Settings
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Use NAS as primary storage</strong>
|
||||
<small>
|
||||
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
||||
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
||||
When disabled, all files are served from local disk using the same directory schema.
|
||||
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
|
||||
<i class="bi bi-hdd-network"></i> Disable NAS
|
||||
</button>
|
||||
@else
|
||||
<span style="font-size:13px;color:var(--text-2);">NAS is disabled. Re-enabling is handled by the system once a NAS endpoint is reachable.</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($settings['nas_sync_enabled'] === 'true')
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<strong>Repair stuck files</strong>
|
||||
<small>
|
||||
Scans for files that were saved locally but never reached the NAS (e.g. due to a
|
||||
connection error during upload or edit). Uploads them to the NAS, then removes the
|
||||
local copies. Safe to run at any time — nothing is deleted until the NAS confirms receipt.
|
||||
</small>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button type="button" id="nasRepairScanBtn" class="adm-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-search" id="nasRepairScanIcon"></i> Scan
|
||||
</button>
|
||||
<button type="button" id="nasRepairFixBtn" class="adm-btn adm-btn-primary" style="white-space:nowrap;display:none;">
|
||||
<i class="bi bi-arrow-repeat" id="nasRepairFixIcon"></i> Fix All
|
||||
</button>
|
||||
</div>
|
||||
<div id="nasRepairResult" class="nas-repair-result" style="display:none;">
|
||||
<div id="nasRepairResultInner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── NAS File Browser ──────────────────────────────────────── --}}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
NAS File Browser
|
||||
</div>
|
||||
<div class="settings-section-body" style="padding: 0;">
|
||||
@include('nas-file-manager::file-manager', [
|
||||
'nodes' => $nodes,
|
||||
'canEdit' => true,
|
||||
'title' => 'NAS Storage Browser',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
|
||||
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
|
||||
|
||||
<div id="nasDisableStep1">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
|
||||
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
|
||||
|
||||
<div style="display:grid;gap:12px;margin-bottom:24px;">
|
||||
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
|
||||
<strong style="font-size:14px;">Copy all files to local disk</strong>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/…</code>. Same directory structure — everything keeps working. May take a while.</p>
|
||||
</div>
|
||||
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
|
||||
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nasDisableStep2Migrate" style="display:none;">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk…</h3>
|
||||
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting…</p>
|
||||
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
|
||||
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
|
||||
</div>
|
||||
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
|
||||
<div id="nasDisableDone" style="display:none;">
|
||||
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
|
||||
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
|
||||
</div>
|
||||
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="nasDisableStep2Fresh" style="display:none;">
|
||||
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
|
||||
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
|
||||
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input-full" style="margin-bottom:8px;">
|
||||
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete & Disable NAS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" defer></script>
|
||||
<script>
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── NAS Repair ────────────────────────────────────────────────
|
||||
(function () {
|
||||
const scanBtn = document.getElementById('nasRepairScanBtn');
|
||||
const fixBtn = document.getElementById('nasRepairFixBtn');
|
||||
const resultEl = document.getElementById('nasRepairResult');
|
||||
const inner = document.getElementById('nasRepairResultInner');
|
||||
const scanIcon = document.getElementById('nasRepairScanIcon');
|
||||
const fixIcon = document.getElementById('nasRepairFixIcon');
|
||||
if (! scanBtn) return;
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
let stuckCount = 0;
|
||||
|
||||
function showResult(html, type = 'info') {
|
||||
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-2)' };
|
||||
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:6px 0;">${html}</div>`;
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', async function () {
|
||||
scanBtn.disabled = true;
|
||||
fixBtn.style.display = 'none';
|
||||
scanIcon.className = 'bi bi-arrow-repeat spin';
|
||||
showResult('Scanning…', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify({ scan_only: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
stuckCount = data.stuck ?? 0;
|
||||
|
||||
if (stuckCount === 0) {
|
||||
showResult('✅ All clear — no stuck local files found.', 'success');
|
||||
} else {
|
||||
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
|
||||
if (data.details && data.details.length) {
|
||||
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||
}
|
||||
html += `<p style="margin-top:10px;color:var(--text-2);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
|
||||
showResult(html, 'warning');
|
||||
fixBtn.style.display = '';
|
||||
}
|
||||
} catch (e) {
|
||||
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
|
||||
}
|
||||
|
||||
scanIcon.className = 'bi bi-search';
|
||||
scanBtn.disabled = false;
|
||||
});
|
||||
|
||||
fixBtn.addEventListener('click', async function () {
|
||||
fixBtn.disabled = true;
|
||||
scanBtn.disabled = true;
|
||||
fixIcon.className = 'bi bi-arrow-repeat spin';
|
||||
showResult('Uploading to NAS…', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ url("/admin/nas-repair") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
const type = data.failed > 0 ? 'warning' : 'success';
|
||||
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
|
||||
if (data.details && data.details.length) {
|
||||
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||
}
|
||||
showResult(html, type);
|
||||
fixBtn.style.display = 'none';
|
||||
} catch (e) {
|
||||
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
|
||||
}
|
||||
|
||||
fixIcon.className = 'bi bi-arrow-repeat';
|
||||
fixBtn.disabled = false;
|
||||
scanBtn.disabled = false;
|
||||
});
|
||||
})();
|
||||
|
||||
// ── NAS Disable Modal ────────────────────────────────────────
|
||||
let _nasOpt = null;
|
||||
let _nasPollTimer = null;
|
||||
|
||||
function openNasDisableModal() {
|
||||
_nasOpt = null;
|
||||
document.getElementById('nasDisableModal').style.display = 'flex';
|
||||
document.getElementById('nasDisableStep1').style.display = 'block';
|
||||
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
|
||||
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
|
||||
document.getElementById('nasDisableNextBtn').disabled = true;
|
||||
['optMigrate','optFresh'].forEach(id => {
|
||||
document.getElementById(id).style.borderColor = 'var(--border)';
|
||||
});
|
||||
}
|
||||
function closeNasDisableModal() {
|
||||
if (_nasPollTimer) clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableModal').style.display = 'none';
|
||||
}
|
||||
function selectNasOpt(opt) {
|
||||
_nasOpt = opt;
|
||||
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
|
||||
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
|
||||
document.getElementById('nasDisableNextBtn').disabled = false;
|
||||
}
|
||||
function nasDisableNext() {
|
||||
document.getElementById('nasDisableStep1').style.display = 'none';
|
||||
if (_nasOpt === 'migrate') {
|
||||
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
|
||||
nasStartMigration();
|
||||
} else {
|
||||
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
|
||||
}
|
||||
}
|
||||
async function nasStartMigration() {
|
||||
try {
|
||||
await fetch('{{ route("admin.nas.disable") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ mode: 'migrate' }),
|
||||
});
|
||||
} catch(e) {
|
||||
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
|
||||
document.getElementById('nasDisableError').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
_nasPollTimer = setInterval(nasPollProgress, 2000);
|
||||
}
|
||||
async function nasPollProgress() {
|
||||
try {
|
||||
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
|
||||
const d = await r.json();
|
||||
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
|
||||
document.getElementById('nasDisableBar').style.width = pct + '%';
|
||||
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
|
||||
document.getElementById('nasDisablePhase').textContent = d.phase || '';
|
||||
if (d.error) {
|
||||
clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
|
||||
document.getElementById('nasDisableError').style.display = 'block';
|
||||
}
|
||||
if (d.done) {
|
||||
clearInterval(_nasPollTimer);
|
||||
document.getElementById('nasDisableBar').style.width = '100%';
|
||||
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
|
||||
document.getElementById('nasDisableDone').style.display = 'block';
|
||||
}
|
||||
} catch(e) { /* network blip, keep polling */ }
|
||||
}
|
||||
async function nasDisableFreshConfirm() {
|
||||
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
|
||||
const errEl = document.getElementById('nasDeleteConfirmError');
|
||||
if (val !== 'DELETE') {
|
||||
errEl.textContent = 'Type DELETE (all caps) to confirm.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
const r = await fetch('{{ route("admin.nas.disable") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ mode: 'fresh' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
closeNasDisableModal();
|
||||
location.reload();
|
||||
} else {
|
||||
errEl.textContent = d.message || 'An error occurred.';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch(e) {
|
||||
errEl.textContent = 'Failed: ' + e.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,40 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'OG Previews')
|
||||
|
||||
@section('content')
|
||||
<div style="padding: 24px;">
|
||||
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
|
||||
<p style="color: #aaa; margin: 0 0 20px;">
|
||||
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
|
||||
when a sports match video is shared. Generated on save; regenerated on
|
||||
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
|
||||
</p>
|
||||
|
||||
@if($files->isEmpty())
|
||||
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
|
||||
@else
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
|
||||
@foreach($files as $f)
|
||||
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
|
||||
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
|
||||
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
|
||||
alt="{{ $f['name'] }}"
|
||||
style="display: block; width: 100%; height: auto;">
|
||||
</a>
|
||||
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
|
||||
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
|
||||
{{ $f['name'] }}
|
||||
</div>
|
||||
<div style="color: #888; margin-top: 4px;">
|
||||
{{ number_format($f['size'] / 1024, 1) }} KB ·
|
||||
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,124 @@
|
||||
{{-- Shared styles for all admin settings-style pages: GPU, NAS, Backup, Settings. --}}
|
||||
<style>
|
||||
.settings-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-section-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.settings-section-header i { color: var(--brand); font-size: 16px; }
|
||||
.settings-section-body { padding: 22px; }
|
||||
|
||||
.setting-row {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px; padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.setting-row:first-child { padding-top: 0; }
|
||||
.setting-label { flex: 1; min-width: 0; }
|
||||
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
|
||||
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
|
||||
.setting-control { flex-shrink: 0; min-width: 220px; display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
|
||||
.toggle-switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; display: inline-block; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-track {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--border-light); border-radius: 12px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute; top: 3px; left: 3px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #fff; transition: transform .2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
|
||||
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
|
||||
.toggle-label { font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* Select / text inputs in setting rows */
|
||||
.adm-select-full,
|
||||
.adm-input-full {
|
||||
width: 100%; height: 38px; box-sizing: border-box;
|
||||
background: var(--bg); border: 1px solid var(--border-light);
|
||||
border-radius: 8px; color: var(--text); font-size: 13px;
|
||||
padding: 0 12px; outline: none; font-family: inherit;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.adm-select-full { cursor: pointer; }
|
||||
.adm-select-full:focus,
|
||||
.adm-input-full:focus { border-color: var(--brand); }
|
||||
.adm-select-full option { background: #1e1e1e; }
|
||||
|
||||
/* GPU cards */
|
||||
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
|
||||
.gpu-card {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, background .15s;
|
||||
position: relative;
|
||||
}
|
||||
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
|
||||
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
||||
.gpu-card-check {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 2px solid var(--border); background: transparent;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; color: #fff;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
|
||||
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
|
||||
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.gpu-stat:last-child { margin-bottom: 0; }
|
||||
.gpu-stat-val { color: var(--text); font-weight: 500; }
|
||||
.mem-bar-wrap { margin-top: 8px; }
|
||||
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
|
||||
|
||||
/* No GPU state */
|
||||
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
|
||||
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
|
||||
|
||||
/* Status chip */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
|
||||
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
|
||||
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
/* Encoder option cards */
|
||||
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||
.enc-card {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px 14px;
|
||||
cursor: pointer; transition: border-color .15s;
|
||||
text-align: left;
|
||||
}
|
||||
.enc-card:hover { border-color: #444; }
|
||||
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
||||
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
|
||||
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
|
||||
|
||||
.save-bar {
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 12px; padding: 4px 0 24px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
|
||||
<div class="country-row">
|
||||
<span class="country-rank">{{ $i + 1 }}</span>
|
||||
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
|
||||
<td>
|
||||
@if($view->country)
|
||||
{!! flagEmoji($view->country) !!}
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
|
||||
@else
|
||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||
@endif
|
||||
@@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
|
||||
|
||||
// ── Country chart ───────────────────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
|
||||
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
||||
const countryMax = Math.max(...countryData);
|
||||
|
||||
|
||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||
</div>
|
||||
@endif
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
||||
{{-- Full title in the title attribute so the
|
||||
admin can hover and read the whole thing
|
||||
when the visible row ellipsizes. --}}
|
||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||
title="{{ $video->title }}">
|
||||
{{ $video->title }}
|
||||
</div>
|
||||
@if($video->description)
|
||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
||||
{{ Str::limit($video->description, 55) }}
|
||||
@php
|
||||
// Descriptions are stored as sanitized rich HTML
|
||||
// (see HtmlSanitizer). Blade's {{ }} would show
|
||||
// the escaped tags verbatim in the admin table,
|
||||
// so strip them to a plain-text preview — and
|
||||
// put the full stripped text in the tooltip too.
|
||||
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
|
||||
@endphp
|
||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||
title="{{ $descPlain }}">
|
||||
{{ Str::limit($descPlain, 55) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
|
||||
{{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
|
||||
the year dropdown at (this year − 13). --}}
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
|
||||
:max-year="(int) date('Y') - 13" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -14,113 +14,8 @@
|
||||
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
|
||||
@endphp
|
||||
|
||||
@once
|
||||
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
|
||||
<script src="{{ asset('js/cropme.min.js') }}"></script>
|
||||
<style>
|
||||
/* ── TakeOne Cropper Modal ─────────────────────────── */
|
||||
.tc-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 10100;
|
||||
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
|
||||
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.tc-modal {
|
||||
background: #141414; border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 18px; width: min(540px, 95vw);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.75);
|
||||
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
|
||||
}
|
||||
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
.tc-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.07);
|
||||
}
|
||||
.tc-modal-title {
|
||||
font-size: 15px; font-weight: 700; color: #fff;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.tc-modal-title i { color: #ef4444; }
|
||||
.tc-modal-close {
|
||||
background: none; border: none; color: rgba(255,255,255,.45);
|
||||
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
|
||||
border-radius: 6px; transition: color .15s, background .15s;
|
||||
}
|
||||
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
|
||||
.tc-modal-body { padding: 16px 20px; }
|
||||
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.tc-file-label {
|
||||
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
|
||||
height: 36px; padding: 0 14px; border-radius: 8px;
|
||||
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
|
||||
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.tc-file-label:hover { background: rgba(255,255,255,.13); }
|
||||
.tc-file-name {
|
||||
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tc-canvas {
|
||||
width: 100%; height: 320px; background: #0d0d0d;
|
||||
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
.tc-placeholder {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
|
||||
}
|
||||
.tc-placeholder i { font-size: 42px; }
|
||||
.tc-placeholder span { font-size: 13px; }
|
||||
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
|
||||
.tc-control { flex: 1; }
|
||||
.tc-control-label {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.tc-range {
|
||||
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
|
||||
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
|
||||
}
|
||||
.tc-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 15px; height: 15px;
|
||||
border-radius: 50%; background: #ef4444; cursor: pointer;
|
||||
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
|
||||
}
|
||||
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
|
||||
.tc-range::-moz-range-thumb {
|
||||
width: 15px; height: 15px; border: none;
|
||||
border-radius: 50%; background: #ef4444; cursor: pointer;
|
||||
}
|
||||
.tc-modal-footer {
|
||||
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
|
||||
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
|
||||
}
|
||||
.tc-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
height: 38px; padding: 0 18px; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
|
||||
transition: background .15s, transform .1s, opacity .15s;
|
||||
}
|
||||
.tc-btn-ghost {
|
||||
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
|
||||
.tc-btn-as-is {
|
||||
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
|
||||
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
|
||||
}
|
||||
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
|
||||
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
|
||||
.tc-btn-primary { background: #ef4444; color: #fff; }
|
||||
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
|
||||
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
</style>
|
||||
@endonce
|
||||
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
|
||||
so they survive SPA-nav innerHTML swaps on #main. --}}
|
||||
|
||||
{{-- Modal --}}
|
||||
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
|
||||
@@ -227,7 +122,12 @@
|
||||
|
||||
/* ── Open / close ── */
|
||||
function openModal() {
|
||||
document.getElementById('tcOverlay_' + id).classList.add('open');
|
||||
var ov = document.getElementById('tcOverlay_' + id);
|
||||
// Reparent to <body> at open time — a transformed ancestor (e.g. an
|
||||
// opening Bootstrap modal) would otherwise constrain our position:fixed
|
||||
// overlay to its own stacking context and clip Cropme's canvas.
|
||||
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
|
||||
ov.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
window['openCropperModal_' + id] = openModal;
|
||||
@@ -240,42 +140,161 @@
|
||||
if (e.target === this) window.closeCropperModal(id);
|
||||
});
|
||||
|
||||
/* ── Preload a File into the cropper ── */
|
||||
/* ── Preload a File into the cropper ─────────────────────────────────
|
||||
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
|
||||
* data URLs, which are slow / can silently fail with large images.
|
||||
*/
|
||||
function preloadFile(file) {
|
||||
if (!file) return;
|
||||
originalFile = file;
|
||||
document.getElementById('tcFileName_' + id).textContent = file.name;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { initCropper(e.target.result); };
|
||||
reader.readAsDataURL(file);
|
||||
var url = URL.createObjectURL(file);
|
||||
initCropper(url);
|
||||
}
|
||||
window['tcPreload_' + id] = preloadFile;
|
||||
|
||||
function initCropper(dataUrl) {
|
||||
/*
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
* DIY cropper — no library. Renders the loaded image in the canvas
|
||||
* with a viewport frame of the desired vw:vh aspect (dashed red
|
||||
* border, dark scrim outside via box-shadow). Pan by dragging the
|
||||
* image, zoom via the slider (or wheel), rotate via the slider.
|
||||
* Save clips the visible viewport region to an offscreen canvas.
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
var _st = null; // cropper state — replaces Cropme's `cropperInst`
|
||||
|
||||
function initCropper(imageUrl) {
|
||||
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
||||
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
||||
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
||||
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
|
||||
canvas.innerHTML = '';
|
||||
canvas.className = 'tc-canvas tc-canvas-active';
|
||||
|
||||
cropperInst = new Cropme(canvas, {
|
||||
container: { width: '100%', height: 320 },
|
||||
viewport: {
|
||||
width: vw, height: vh,
|
||||
type: shape,
|
||||
border: { enable: true, width: 2, color: '#ef4444' }
|
||||
},
|
||||
transformOrigin: 'viewport',
|
||||
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
|
||||
rotation: { enable: true, slider: false }
|
||||
});
|
||||
cropperInst.bind({ url: dataUrl }).then(function () {
|
||||
document.getElementById('tcZoom_' + id).value = 0;
|
||||
document.getElementById('tcRot_' + id).value = 0;
|
||||
});
|
||||
// ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
|
||||
var img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform-origin:center center;' +
|
||||
'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
|
||||
var frame = document.createElement('div');
|
||||
frame.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
|
||||
'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
|
||||
'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
|
||||
(shape === 'circle' ? 'border-radius:50%;' : '');
|
||||
canvas.appendChild(img);
|
||||
canvas.appendChild(frame);
|
||||
|
||||
// Reset sliders
|
||||
var zoomEl = document.getElementById('tcZoom_' + id);
|
||||
var rotEl = document.getElementById('tcRot_' + id);
|
||||
zoomEl.value = 0; rotEl.value = 0;
|
||||
|
||||
// Init state
|
||||
_st = {
|
||||
img: img, frame: frame, canvas: canvas,
|
||||
imgW: 0, imgH: 0,
|
||||
canvasW: 0, canvasH: 0,
|
||||
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
|
||||
baseScale: 1, // scale that makes image "cover" the viewport
|
||||
scale: 1, deg: 0, tx: 0, ty: 0,
|
||||
ready: false,
|
||||
imageUrl: imageUrl,
|
||||
};
|
||||
|
||||
img.onload = function () {
|
||||
_st.imgW = img.naturalWidth || 1;
|
||||
_st.imgH = img.naturalHeight || 1;
|
||||
layoutCropper();
|
||||
_st.ready = true;
|
||||
};
|
||||
img.onerror = function (e) {
|
||||
console.error('[image-cropper]', id, 'image failed to load', e);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
// If already cached, onload may have fired synchronously — no-op if not.
|
||||
if (img.complete && img.naturalWidth > 0) img.onload();
|
||||
}
|
||||
|
||||
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
|
||||
function layoutCropper() {
|
||||
var s = _st; if (!s) return;
|
||||
s.canvasW = s.canvas.clientWidth || 320;
|
||||
s.canvasH = s.canvas.clientHeight || 320;
|
||||
|
||||
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
|
||||
var pad = 20;
|
||||
var maxW = s.canvasW - pad * 2;
|
||||
var maxH = s.canvasH - pad * 2;
|
||||
var vAspect = vw / vh;
|
||||
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
|
||||
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
|
||||
s.frame.style.width = s.vpW + 'px';
|
||||
s.frame.style.height = s.vpH + 'px';
|
||||
|
||||
// Base scale: image must at least cover the viewport.
|
||||
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
|
||||
s.scale = s.baseScale;
|
||||
s.tx = 0; s.ty = 0; s.deg = 0;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
var s = _st; if (!s) return;
|
||||
// Image is centered via top/left 50% translate + own translate + scale + rotate.
|
||||
// Set explicit width so scale multiplies pixels rather than object-fit.
|
||||
s.img.style.width = s.imgW + 'px';
|
||||
s.img.style.height = s.imgH + 'px';
|
||||
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
|
||||
s.img.style.marginTop = (-s.imgH / 2) + 'px';
|
||||
s.img.style.transform =
|
||||
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
|
||||
' rotate(' + s.deg + 'deg)' +
|
||||
' scale(' + s.scale + ')';
|
||||
}
|
||||
|
||||
// Drag to pan (mouse + touch)
|
||||
(function bindPan() {
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
|
||||
function down(e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
dragging = true;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
function move(e) {
|
||||
if (!dragging || !_st) return;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
_st.tx = otx + (p.clientX - sx);
|
||||
_st.ty = oty + (p.clientY - sy);
|
||||
applyTransform();
|
||||
}
|
||||
function up() { dragging = false; canvas.style.cursor = 'grab'; }
|
||||
canvas.addEventListener('mousedown', down);
|
||||
canvas.addEventListener('touchstart', down, { passive: false });
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('touchmove', move, { passive: false });
|
||||
window.addEventListener('mouseup', up);
|
||||
window.addEventListener('touchend', up);
|
||||
// Wheel zoom
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
|
||||
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
|
||||
document.getElementById('tcZoom_' + id).value = pct;
|
||||
applyTransform();
|
||||
}, { passive: false });
|
||||
canvas.style.cursor = 'grab';
|
||||
})();
|
||||
|
||||
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
||||
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
||||
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
||||
@@ -287,14 +306,16 @@
|
||||
|
||||
/* ── Zoom / rotate sliders ── */
|
||||
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
||||
if (!cropperInst || !cropperInst.properties.image) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
|
||||
var p = parseFloat(this.value) / 100;
|
||||
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
|
||||
var s = cropperInst.properties;
|
||||
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
|
||||
_st.scale = _st.baseScale + (_st.baseScale * 5) * p;
|
||||
applyTransform();
|
||||
});
|
||||
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
||||
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
|
||||
if (!_st || !_st.ready) return;
|
||||
_st.deg = parseInt(this.value, 10) || 0;
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
/* ── Helpers ── */
|
||||
@@ -339,7 +360,15 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
|
||||
body: JSON.stringify({ path: res.path })
|
||||
}).then(function () { return res; });
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||
.then(function (upd) {
|
||||
// The update step moves the temp upload to its final home (e.g. NAS)
|
||||
// and deletes the temp, so res.url is now dead. Prefer the canonical
|
||||
// URL the update endpoint returns; fall back to res.url otherwise.
|
||||
if (upd && upd.url) res = Object.assign({}, res, { url: upd.url });
|
||||
return res;
|
||||
});
|
||||
}
|
||||
return res;
|
||||
})
|
||||
@@ -347,43 +376,82 @@
|
||||
.catch(onFail);
|
||||
}
|
||||
|
||||
/* ── Crop & Save ── */
|
||||
/* ── Crop & Save ─────────────────────────────────────────────────────
|
||||
* Renders the visible viewport region to an offscreen canvas at the
|
||||
* output size, then returns the result as a JPEG data URL.
|
||||
* The image is transformed by (translate → rotate → scale) — the
|
||||
* offscreen canvas replays those same transforms scaled up to the
|
||||
* output resolution so nothing is lost.
|
||||
*/
|
||||
function renderCroppedBase64() {
|
||||
var s = _st; if (!s || !s.ready) return null;
|
||||
var outW = outputWidth > 0 ? outputWidth : vw;
|
||||
var outH = Math.round(outW * (vh / vw));
|
||||
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = outW;
|
||||
cv.height = outH;
|
||||
var ctx = cv.getContext('2d');
|
||||
|
||||
// Circle crop mask (matches the round viewport look on save)
|
||||
if (shape === 'circle') {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
// 1 display px inside the viewport == (outW / s.vpW) output px
|
||||
var pxRatio = outW / s.vpW;
|
||||
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.scale(pxRatio, pxRatio);
|
||||
ctx.translate(s.tx, s.ty);
|
||||
ctx.rotate(s.deg * Math.PI / 180);
|
||||
ctx.scale(s.scale, s.scale);
|
||||
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
|
||||
|
||||
if (shape === 'circle') ctx.restore();
|
||||
|
||||
return cv.toDataURL('image/jpeg', 0.92);
|
||||
}
|
||||
|
||||
window['tcSave_' + id] = function () {
|
||||
if (!cropperInst) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
var btn = document.getElementById('tcSaveBtn_' + id);
|
||||
var txt = document.getElementById('tcSaveBtnText_' + id);
|
||||
btn.disabled = true;
|
||||
txt.textContent = 'Saving…';
|
||||
|
||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
||||
var base64 = renderCroppedBase64();
|
||||
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
|
||||
|
||||
cropperInst.crop(cropOpts).then(function (base64) {
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
} else {
|
||||
uploadToServer(base64, function (res) {
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
||||
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
} else {
|
||||
uploadToServer(base64, function (res) {
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
||||
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
}, function (err) {
|
||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
}, function (err) {
|
||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Upload as-is ── */
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
{{-- Shared card styles (video-card + playlist-card). Extracted so the CSS
|
||||
loads on pages that render playlist cards but no video cards, e.g.
|
||||
/videos?filter=playlists. Wrapped in @once so it emits only once. --}}
|
||||
@once
|
||||
<style>
|
||||
/* Base styles for video card — every card in the grid renders at the same
|
||||
overall size regardless of type (video / shorts / match / playlist). The
|
||||
grid handles equal widths; height parity is enforced here by making the
|
||||
card a column-flex container, the thumb a strict 16/9 box, and every
|
||||
text row a fixed line-height so missing channel/meta rows never shrink
|
||||
the card. */
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* When the 3-dot menu is open, promote this card above its siblings so
|
||||
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
|
||||
covered by later cards in the grid. Cards are position:static by
|
||||
default, so siblings later in the DOM naturally paint on top even
|
||||
though the dropdown itself has a high z-index — the stacking is
|
||||
compared at the card level, not the dropdown level. `:has()` triggers
|
||||
only while a menu is `.show`ing, so this doesn't affect the normal
|
||||
grid layout. */
|
||||
.yt-video-card:has(.dropdown-menu.show) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
|
||||
(to hard-cap card height for grid parity), which was clipping the
|
||||
dropdown's bottom edge. Allow the dropdown to escape by giving its
|
||||
wrapper a stacking context and letting overflow leak upward from that
|
||||
ancestor only when a menu is open. */
|
||||
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer while thumbnail loads */
|
||||
.yt-video-card .yt-video-thumb::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: thumb-shimmer 1.4s ease infinite;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes thumb-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb.loaded::before {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.yt-video-card:hover .yt-video-thumb img.loaded {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Match the still-image thumbnail: always cover the 16/9 box so
|
||||
portrait and landscape sources render at the same visible size,
|
||||
instead of letterboxing (which made portrait previews look
|
||||
smaller than landscape ones). */
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: #000;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb video.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Audio preview overlay (equalizer) */
|
||||
.audio-preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.yt-video-thumb.audio-playing .audio-preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.audio-eq {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.audio-eq span {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #e61e1e;
|
||||
animation: eq-bar 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.audio-eq span:nth-child(1) { height: 6px; animation-delay: 0s; }
|
||||
.audio-eq span:nth-child(2) { height: 14px; animation-delay: 0.15s; }
|
||||
.audio-eq span:nth-child(3) { height: 20px; animation-delay: 0.05s; }
|
||||
.audio-eq span:nth-child(4) { height: 12px; animation-delay: 0.2s; }
|
||||
.audio-eq span:nth-child(5) { height: 8px; animation-delay: 0.1s; }
|
||||
|
||||
@keyframes eq-bar {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(230, 30, 30, 0.9);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-track-lang-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
letter-spacing: 0.3px;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.yt-video-card .yt-track-lang-badge .fi {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-visibility-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-visibility-private {
|
||||
background: rgba(220, 38, 38, 0.88);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-visibility-unlisted {
|
||||
background: rgba(30, 30, 30, 0.82);
|
||||
color: #facc15;
|
||||
border: 1px solid rgba(250, 204, 21, 0.4);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-info {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
/* Reserve a floor for the info block so every card lands at the same
|
||||
height when titles are short. Two-line titles are allowed to grow
|
||||
the block vertically — overflow:visible so no row is hidden. */
|
||||
min-height: 76px;
|
||||
}
|
||||
/* On channel pages the avatar + channel-name row is suppressed by the
|
||||
component's hideChannelInfo prop; drop the floor so the card doesn't
|
||||
leave empty space where the row used to be. */
|
||||
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
|
||||
.yt-video-card .yt-video-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon img.loaded {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
|
||||
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
|
||||
break mid-word so they can't widen the card beyond its grid track.
|
||||
Line-clamp replaces the single-line ellipsis the old rule used. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-height: calc(1.3em * 2);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||
the single-line layout. */
|
||||
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
|
||||
.yt-video-card .yt-video-title a .fi { display: inline-block; }
|
||||
.vc-lang-flag {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-name,
|
||||
.yt-video-card .yt-video-meta {
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.yt-video-card a.yt-channel-name {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.yt-video-card a.yt-channel-name:hover { color: #fff; }
|
||||
|
||||
/* Type label in meta row */
|
||||
.yt-type-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.yt-type-music { color: #c084fc; }
|
||||
.yt-type-match { color: #60a5fa; }
|
||||
.yt-type-generic { color: #f87171; }
|
||||
|
||||
/* More button — visible only on hover (touch devices always show it) */
|
||||
.yt-video-card .yt-more-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.yt-video-card .yt-more-btn { opacity: 0; }
|
||||
.yt-video-card:hover .yt-more-btn,
|
||||
.yt-video-card .yt-more-btn:focus,
|
||||
.yt-video-card .yt-more-btn[aria-expanded="true"] { opacity: 1; }
|
||||
}
|
||||
.yt-video-card .yt-more-btn:hover {
|
||||
background: #3f3f3f;
|
||||
}
|
||||
|
||||
/* Dropdown menu styles - use Bootstrap defaults */
|
||||
.yt-video-card .dropdown-menu-dark {
|
||||
background: #282828;
|
||||
border: 1px solid #3f3f3f;
|
||||
border-radius: 12px;
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-item {
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-item:hover {
|
||||
background: #3f3f3f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-item.text-danger {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-item.text-danger:hover {
|
||||
background: #3f3f3f;
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.yt-video-card .dropdown-divider {
|
||||
border-color: #3f3f3f;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Small size styles */
|
||||
.yt-video-card-sm .yt-video-thumb {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.yt-video-card-sm .yt-video-info {
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-video-card-sm .yt-channel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.yt-video-card-sm .yt-video-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yt-video-card-sm .yt-channel-name,
|
||||
.yt-video-card-sm .yt-video-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card-sm .yt-more-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@endonce
|
||||
@@ -0,0 +1,377 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
SCOREBAR MINI — the same match scorebar shown on the full player,
|
||||
rendered inside a video-card thumbnail during hover playback.
|
||||
|
||||
Uses the design's fixed 1150 px canvas (identical to the main player's
|
||||
msb-scale block). Every card scales its own canvas via ResizeObserver
|
||||
so the scorebar keeps its pixel-perfect proportions at any card size.
|
||||
|
||||
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
|
||||
and 0-0 / 0:00. No live-scoring script — hover previews cover the
|
||||
first few seconds of the match where those values are always the
|
||||
baseline anyway.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$sbm_hd = $video->sportsMatch?->headerData();
|
||||
$sbm_red = $sbm_hd['red'] ?? null;
|
||||
$sbm_blue = $sbm_hd['blue'] ?? null;
|
||||
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
|
||||
@endphp
|
||||
|
||||
@if ($sbm_show)
|
||||
@php
|
||||
$sbm_redName = $sbm_red['name'] ?? 'RED';
|
||||
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
|
||||
$sbm_redClub = $sbm_red['club'] ?? '';
|
||||
$sbm_blueClub = $sbm_blue['club'] ?? '';
|
||||
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
|
||||
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
|
||||
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
|
||||
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
|
||||
|
||||
// Live-scoring payload — same shape as the full-player scoreboard uses.
|
||||
// Rounds drive the round label + per-round clock. Points give us the
|
||||
// score at any t (last point ≤ t defines the score).
|
||||
$sbm_rounds = $video->matchRounds()
|
||||
->orderBy('round_number')
|
||||
->get(['round_number', 'name', 'start_time_seconds'])
|
||||
->map(fn ($r) => [
|
||||
'n' => (int) $r->round_number,
|
||||
'name' => (string) ($r->name ?? ''),
|
||||
'start' => (float) ($r->start_time_seconds ?? 0),
|
||||
])->values()->all();
|
||||
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
|
||||
|
||||
$sbm_points = $video->matchPoints()
|
||||
->orderBy('timestamp_seconds')
|
||||
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||
->flatMap(function ($p) {
|
||||
// Same rule the full-player uses: a "both" event emits two feed rows
|
||||
// — one per corner — so the ticker shows both fighters scoring.
|
||||
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
|
||||
$rows = [[
|
||||
't' => (float) $p->timestamp_seconds,
|
||||
'action' => (string) ($p->action ?? 'Point'),
|
||||
'pts' => (int) $p->points,
|
||||
'side' => $side,
|
||||
'sr' => (int) ($p->score_red ?? 0),
|
||||
'sb' => (int) ($p->score_blue ?? 0),
|
||||
]];
|
||||
if ($p->competitor === 'both') {
|
||||
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
|
||||
}
|
||||
return $rows;
|
||||
})->values()->all();
|
||||
|
||||
$sbm_sport = $sbm_hd['sport'] ?? null;
|
||||
|
||||
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
@endphp
|
||||
|
||||
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
|
||||
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
|
||||
top-right of the thumb. Populated by the sbm live-sync JS. --}}
|
||||
<div class="sbm-feed-scale">
|
||||
<div class="sbm-feed" data-sbm-feed>
|
||||
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
|
||||
<div class="sbm-feed-list" data-sbm-feed-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sbm-scale">
|
||||
{{-- ══ Scorebar markup — same design + inline styles as the
|
||||
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
|
||||
of the live-sync data-msb hooks. ══ --}}
|
||||
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
|
||||
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
@if ($sbm_redFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
|
||||
</div>
|
||||
@if ($sbm_redClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CENTER: round + clock --}}
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE / AO --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
|
||||
@if ($sbm_blueFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($sbm_blueClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@once
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
|
||||
Sits above the hover-preview <video> (z-index 6, one above the
|
||||
video at 5) and only appears while the video is active. ══ */
|
||||
.yt-video-thumb .sbm {
|
||||
position: absolute; inset: 0;
|
||||
z-index: 6;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #efe9e0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease .05s;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--sbm-scale: 0.4;
|
||||
}
|
||||
.sbm * { box-sizing: border-box; }
|
||||
|
||||
/* Show only while the hover video is playing (playVideo() adds .active) */
|
||||
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
|
||||
|
||||
.sbm .sbm-scale {
|
||||
position: absolute; bottom: 0; left: 0;
|
||||
width: 1150px; height: 100px;
|
||||
transform-origin: bottom left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
}
|
||||
|
||||
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
|
||||
full-player msb-feed, so the sizes and positions are pixel-identical.
|
||||
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
|
||||
shrinks/grows in lockstep with the rest of the overlay. ── */
|
||||
.sbm .sbm-feed-scale {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 1150px; height: 100%;
|
||||
transform-origin: top left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
|
||||
videos/partials/match/scoreboard/styles.blade.php — only the class
|
||||
names are prefixed .sbm- to avoid colliding with the full player
|
||||
when a card is opened as a sub-page. */
|
||||
.sbm .sbm-feed {
|
||||
position: absolute; top: 22px; right: 24px;
|
||||
width: 262px;
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
}
|
||||
.sbm .sbm-feed-hdr {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
||||
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||
}
|
||||
.sbm .sbm-feed-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #e8534a; animation: sbmPulse 1.6s infinite;
|
||||
}
|
||||
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbm .sbm-feed-entry {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
|
||||
border-right: 3px solid transparent;
|
||||
animation: sbmRiseIn .45s ease both;
|
||||
}
|
||||
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||
|
||||
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
|
||||
down for cards — below ~340 px it becomes illegible). */
|
||||
.sbm.sbm-small .sbm-feed { display: none; }
|
||||
|
||||
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window._sbmBound) return;
|
||||
window._sbmBound = true;
|
||||
|
||||
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
|
||||
function fit(el) {
|
||||
const host = el.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth;
|
||||
if (!w) return;
|
||||
el.style.setProperty('--sbm-scale', (w / 1150));
|
||||
// Below ~340 px thumb width the ticker would shrink under legibility
|
||||
// (matches the full player's `msb-small` breakpoint at 700 px, halved
|
||||
// for card contexts). Hide the feed on those tiny cards.
|
||||
el.classList.toggle('sbm-small', w < 340);
|
||||
}
|
||||
|
||||
// ── Live scoring sync ─────────────────────────────────────────
|
||||
// Each .sbm carries its match state as a data attribute. Bind the
|
||||
// sibling <video>'s timeupdate to updates on THIS card's scorebar
|
||||
// only — no globals, no ID collisions with other cards on the page.
|
||||
function fmtClock(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||
}
|
||||
function currentRound(rounds, t) {
|
||||
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
|
||||
let cur = rounds[0];
|
||||
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
|
||||
return cur;
|
||||
}
|
||||
function lastPoint(points, t) {
|
||||
if (!points || !points.length) return null;
|
||||
let last = null;
|
||||
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
|
||||
return last;
|
||||
}
|
||||
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
|
||||
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
|
||||
function pointLabel(p, sport) {
|
||||
sport = (sport || '').toLowerCase();
|
||||
if (sport.startsWith('taekwondo')) {
|
||||
if (p.pts >= 4) return 'Turning body';
|
||||
if (p.pts === 3) return 'Head kick';
|
||||
if (p.pts === 2) return 'Body kick';
|
||||
if (p.pts === 1) return 'Punch';
|
||||
}
|
||||
if (p.pts === 3) return 'Ippon';
|
||||
if (p.pts === 2) return 'Waza-ari';
|
||||
if (p.pts === 1) return 'Yuko';
|
||||
return p.action || 'Point';
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||
|
||||
function bindLive(sbm) {
|
||||
let state = null;
|
||||
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
|
||||
catch (e) { return; }
|
||||
if (!state) return;
|
||||
|
||||
const thumb = sbm.parentElement;
|
||||
const video = thumb && thumb.querySelector(':scope > video');
|
||||
if (!video) return;
|
||||
|
||||
const redEl = sbm.querySelector('[data-sbm-red-score]');
|
||||
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
|
||||
const roundEl = sbm.querySelector('[data-sbm-round]');
|
||||
const clockEl = sbm.querySelector('[data-sbm-clock]');
|
||||
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
|
||||
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
|
||||
|
||||
const tick = () => {
|
||||
const t = video.currentTime || 0;
|
||||
const p = lastPoint(state.points, t);
|
||||
const r = currentRound(state.rounds, t);
|
||||
|
||||
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
|
||||
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
|
||||
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
|
||||
|
||||
const roundTxt = r.name || ('Round ' + r.n);
|
||||
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
|
||||
|
||||
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
|
||||
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
|
||||
|
||||
// Live scoring ticker — newest 3 point events, newest on top.
|
||||
if (feedEl) {
|
||||
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
|
||||
const last4 = seen.slice(-4).reverse();
|
||||
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
|
||||
if (sig !== lastFeedSig) {
|
||||
feedEl.innerHTML = last4.map(pt => {
|
||||
const col = feedColor(pt.side);
|
||||
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
|
||||
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
|
||||
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
|
||||
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
lastFeedSig = sig;
|
||||
}
|
||||
}
|
||||
};
|
||||
video.addEventListener('timeupdate', tick);
|
||||
video.addEventListener('seeked', tick);
|
||||
video.addEventListener('loadedmetadata', tick);
|
||||
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
|
||||
video.addEventListener('pause', tick);
|
||||
video.addEventListener('emptied', tick);
|
||||
tick();
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const el = e.target.querySelector(':scope > .sbm');
|
||||
if (el) fit(el);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
function register(el) {
|
||||
fit(el);
|
||||
if (ro) ro.observe(el.parentElement);
|
||||
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sbm').forEach(register);
|
||||
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const n of m.addedNodes) {
|
||||
if (!(n instanceof Element)) continue;
|
||||
if (n.matches?.('.sbm')) register(n);
|
||||
n.querySelectorAll?.('.sbm').forEach(register);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user