Compare commits

..

No commits in common. "master" and "pre-mobile-responsive-20260303" have entirely different histories.

534 changed files with 8980 additions and 78587 deletions

View File

@ -1,282 +0,0 @@
# Reusable Select Component Usage
This file tracks every page/partial that uses `<x-phone-code-select>`, `<x-country-select>`, `<x-timezone-select>`, or `<x-language-select>`.
**Update this file whenever you add or remove a component from a view.**
When modifying any component or its data source, check all pages in the relevant section below and verify the change works correctly in each context.
---
## Data sources
**`app/Data/Countries.php`** — `App\Data\Countries`
| Method | Used by component |
|---|---|
| `Countries::forPhoneCode()` | `<x-phone-code-select>` |
| `Countries::forCountry()` | `<x-country-select>` |
| `Countries::forTimezone()` | `<x-timezone-select>` |
| `Countries::all()` | All three (via the above methods) |
Adding or renaming a field in `Countries::all()` requires updating the corresponding `for*()` method too.
**`app/Data/Languages.php`** — `App\Data\Languages`
| Method | Used by component |
|---|---|
| `Languages::forLanguage()` | `<x-language-select>` |
| `Languages::all()` | Via `forLanguage()` |
Arabic and English are pinned to the top of the list; all others are sorted alphabetically by English name. Stored value is the ISO 639-1 code (e.g. `"ar"`, `"en"`).
---
## Shared CSS / JS
The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all four component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all four component files**:
- `resources/views/components/phone-code-select.blade.php`
- `resources/views/components/country-select.blade.php`
- `resources/views/components/timezone-select.blade.php`
- `resources/views/components/language-select.blade.php`
The `@once` Blade directive ensures the browser only receives one copy of the CSS/JS even when multiple components are on the same page.
`language-select` also emits an extra `@once('lsd-badge-styles')` block for the `.lsd-code` ISO badge that appears in place of a flag emoji.
---
## `<x-video-insights>`
**File:** `resources/views/components/video-insights.blade.php`
**Props:** `:video``Video` model instance.
**Behaviour:** Renders the Insights tab panel (`<div class="vdb-panel" id="vdb-insights">`), the drill-down modal, all `.ins-*` CSS, and all insights JS (`loadInsights`, `renderInsights`, modal openers, country/day/downloader drill-downs). Only renders if `Auth::id() === $video->user_id`. Must be placed **inside `.vdb-wrap`**, after the About panel, so the tab-switch CSS applies. The parent view must call `loadInsights()` (global, defined by this component) when the Insights tab is activated.
**Data source:** `GET /videos/{video}/insights` (JSON) + drill-down routes `/insights/country/{code}`, `/insights/day/{date}`, `/insights/downloader/{userId}`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/videos/partials/description-box.blade.php` | Inside `.vdb-wrap`, after About panel | Used by all three video type views (generic, match, music) |
| `resources/views/videos/show.blade.php` | Inside `.vdb-wrap`, after About panel | Legacy view (not rendered by controller — kept in sync) |
---
## `<x-social-links-editor>`
**File:** `resources/views/components/social-links-editor.blade.php`
**Props:** `existing` — associative array keyed by platform name (e.g. `['twitter' => 'handle', 'whatsapp' => '97312345678']`).
**Behaviour:** Dynamic add/remove rows; each row has a custom icon dropdown to pick the platform and a text input for the value. Supported platforms: `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `whatsapp`, `website`, `google_location`, `social_phone`, `social_email`. Hidden clear inputs ensure removed entries are cleared on save. Must be placed **inside a `<form>`**.
**DB columns:** `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `website` (legacy), `whatsapp`, `google_location`, `social_phone`, `social_email`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | Social tab of Edit Profile modal | `$socialExisting` array passed from `@php` block above `@section('scripts')` |
---
## `<x-date-picker>`
**File:** `resources/views/components/date-picker.blade.php`
**Stored value:** `YYYY-MM-DD` string in a hidden input (same format as `<input type="date">`).
**Props:** `name`, `id`, `value`, `label`, `required`, `class`, `style`, `minYear` (default 1900), `maxYear` (default current year).
**Behaviour:** Day grid (5 columns, 131), month list (JanuaryDecember), year searchable list (descending). Days auto-constrain on month/year change; invalid selected day resets automatically.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `birthday` | Replaces `<input type="date">` |
| `resources/views/auth/register.blade.php` | `birthday` | Registration form — mandatory |
---
## `<x-image-cropper>`
**File:** `resources/views/components/image-cropper.blade.php`
**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading), `target-input` (form mode: ID of file input the cropped File is set on), `preview-img` (ID of `<img>` updated with the cropped preview), `output-width` (final output px width), `result-callback` (callback mode: name of a global JS fn given the cropped `File`).
**Three operating modes (mutually exclusive, checked in this order):** (1) **callback mode** — when `result-callback` is set, both "Crop & Save" and "Upload as-is" hand the resulting `File` to `window[resultCallback](file)` and do **not** auto-close; the host fn decides when to close (`closeCropperModal(id)`) or load the next image. Used for multi-image queues (cover slides). (2) **form mode** — when `target-input` is set, the cropped File is placed on that file input (DataTransfer) and a `change` event is dispatched. (3) **server mode** — otherwise POSTs base64 to `/image-upload`, optionally POSTs path to `update-url`, then calls `callback(url)`.
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). Exposes per-id globals: `openCropperModal_{id}()`, `tcPreload_{id}(file)`, `closeCropperModal(id)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required.
**Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`.
**Routes needed:** `image.upload` (POST `/image-upload`).
| View file | id / use | Notes |
|---|---|---|
| `resources/views/user/channel.blade.php` | `avatar` — circle 300×300 | Owner only; `update-url = profile.updateAvatar`; callback `onAvatarSaved` |
| `resources/views/user/channel.blade.php` | `banner` — square 500×160 | Owner only; `update-url = profile.updateBanner`; callback `onBannerSaved` |
| `resources/views/layouts/partials/upload-modal.blade.php` | `thumb_upload` — square 448×252 | Form mode; `target-input=thumbnail-modal`; output 1280px |
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `thumb_edit` — square 448×252 | Form mode; `target-input=edit-t1-thumbnail-input`; output 1280px |
| `resources/views/videos/create.blade.php` | `thumb_create_mobile` — square 448×252 | Mobile; `target-input=thumbnail`; output 1280px |
| `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px |
| `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px |
| `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px |
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `slides_edit` — square 448×252 | Callback mode; `result-callback=editSlidesCropDone`; crops each cover slide before it enters the strip (queues multiple) |
| `resources/views/layouts/partials/upload-modal.blade.php` | `slides_upload` — square 448×252 | Callback mode; `result-callback=uploadSlidesCropDone`; cover-slide crop queue |
| `resources/views/videos/create.blade.php` | `slides_create_mobile` — square 448×252 | Mobile; callback mode; `result-callback=cSlidesCropDone`; cover-slide crop queue |
| `resources/views/videos/edit.blade.php` | `slides_edit_mobile` — square 448×252 | Mobile; callback mode; `result-callback=epSlidesCropDone`; cover-slide crop queue |
---
## `<x-gender-select>`
**File:** `resources/views/components/gender-select.blade.php`
**Props:** `name`, `id`, `value` (ISO string: `"male"` or `"female"`), `label`, `required`, `class`, `style`.
**Behaviour:** Custom dropdown with blue ♂ (male) and pink ♀ (female) symbols. No search needed. Stores value as `"male"` or `"female"`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/auth/register.blade.php` | `gender` | Registration form — mandatory |
---
## `<x-phone-code-select>`
Stored value format: `"+973|BH"` (dial_code + pipe + ISO2).
To read only the dial code from a stored value: `explode('|', $value)[0]`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `phone_code` | Paired with `phone_number` text input |
---
## `<x-country-select>`
Stored value: ISO2 code (e.g. `"BH"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `nationality` | Edit Profile form |
| `resources/views/auth/register.blade.php` | `nationality` | Registration form — mandatory |
---
## `<x-timezone-select>`
Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `timezone` | Edit Profile form |
---
## `<x-track-editor-form>`
**File:** `resources/views/components/track-editor-form.blade.php`
**Props:** `prefix` (default `'t1'`), `isPrimary` (bool, default `false`), `languageName`, `languageId`, `titleName`, `titleId`, `descName`, `descId`, `videoFileInputId`.
**Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (`<x-language-select>`), title input, description rich-text editor (`<x-rich-text-editor>`), video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. Adding cover slides routes through the `slides_edit` image-cropper (callback mode `editSlidesCropDone`) — each picked/dropped image is cropped to 16:9 before entering `_editSlidesData`; the live `<x-image-cropper>` instances are defined in edit-video-modal.blade.php.
| View file | Prefix used | Notes |
|---|---|---|
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `t1` | Primary track only; secondary tracks are built via JS (`_editAddExistingTrack`) |
---
## `<x-language-select>`
**File:** `resources/views/components/language-select.blade.php`
**Data source:** `app/Data/Languages.php``Languages::forLanguage()`
**Stored value:** ISO 639-1 code (e.g. `"ar"`, `"en"`, `"fr"`).
**Props:** `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, `style`.
**Icon:** 2-letter uppercase ISO code rendered as a monospace badge (`.lsd-code`) — no flag emoji.
**Arabic and English are always pinned to the top** of the list; all other languages are alphabetical by English name.
**Rule:** This component must be used for every language picker in the application. Never build a custom `<select>` or inline list for language selection.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/layouts/partials/upload-modal.blade.php` | `primary_language` | Inside accordion track 1 body (`id="lang-tracks-section-modal"`); extra track language rows use `LANG_OPTIONS_MODAL` JS constant for inline dynamic CSD |
| `resources/views/videos/create.blade.php` | `primary_language` | Inside accordion track 1 body (`id="lang-tracks-section-create"`); extra track language rows use `LANG_OPTIONS_CREATE` JS constant for inline dynamic CSD |
| `resources/views/videos/create.blade.php` | `primary_language` (`id="video_language_create"`) | Video-mode language field inside `#basic-fields-create` (generic/match). `setAudioMode()` swaps `name="primary_language"` between this and `primary_language_create` so only the active mode's picker submits |
| `resources/views/components/track-editor-form.blade.php` | `$languageName` (prop) | Rendered inside the track editor form; used for primary track in edit-video-modal (prefix `t1`) |
| `resources/views/videos/edit.blade.php` | `primary_language` | Inside `@else` (audio only) block; pre-populated with `value="{{ $video->language ?? '' }}"` |
---
## `<x-rich-text-editor>`
**File:** `resources/views/components/rich-text-editor.blade.php`
**Props:** `name`, `id`, `value` (initial HTML), `placeholder`, `class`, `style`, `minHeight` (default `110px`).
**Server sanitizer:** `app/Support/HtmlSanitizer.php``HtmlSanitizer::clean()` (allowlist, on save) and `HtmlSanitizer::render()` (display; upgrades legacy plain text).
**Stored value:** sanitized HTML. Allowed tags: `p, br, div, span, b/strong, i/em, u, s, h2, h3, ul, ol, li, blockquote, a`. `<a>` may carry `href` (http/https/mailto only), `target="_blank"` (auto `rel=noopener`), and `class` limited to `.action-btn` variants (button links). `style` limited to `text-align`.
**Behaviour:** Renders a hidden `<textarea class="rte-source" name id>` as the form field (source of truth) wrapped in `.rte-wrap`. `window.RTE` builds the toolbar + `contenteditable` editor in JS (so Blade-rendered and JS-generated rows share one implementation) and a `MutationObserver` auto-inits any `.rte-wrap` added later (modals, cloned track rows). Toolbar: bold, italic, underline, strikethrough, heading (H2), bullet/numbered list, quote, align left/center/right, link, button-link (`.action-btn`), emoji, clear formatting. Editor↔textarea stay synced via `input`; external code that sets `textarea.value` must dispatch `new Event('rte:refresh')` to update the editor.
**Rendering:** display HTML via `{!! \App\Support\HtmlSanitizer::render($value) !!}`; truncation is CSS-clamp (`.vdb-clamp`) + JS overflow check, never character-truncation (would break tags).
| View file | Field name / id | Notes |
|---|---|---|
| `resources/views/components/track-editor-form.blade.php` | `$descName` / `$descId` | Description in the Track Editor popup; primary + JS-cloned template tracks (edit-video-modal) |
| `resources/views/layouts/partials/upload-modal.blade.php` | (no name) `lt-track1-desc-modal` + `extra_track_descriptions[]` | Primary desc collected manually into FormData; extra-track rows generated via JS template string (`.rte-wrap` markup) |
| `resources/views/videos/create.blade.php` | `description` `video-description`, (no name) `lt-track1-desc-create`, `extra_track_descriptions[]` | Mobile upload; extra rows are JS template literal markup |
| `resources/views/videos/edit.blade.php` | `description` `edit-description`, `track_description_updates[{id}]` | Mobile edit; per-track rows rendered via Blade `@foreach` |
**Render sites (display):** `resources/views/videos/partials/description-box.blade.php` (generic/match, also music), `resources/views/videos/partials/audio-player.blade.php` (`_updateDescriptionBox` per-track switch). SPA swaps re-run `_vdbCheckOverflow()` in `generic.blade.php` / `match.blade.php`.
---
## Usage example
```blade
{{-- Phone code + number side by side --}}
<div style="display:flex; gap:8px;">
<x-phone-code-select
name="phone_code"
value="+973|BH"
label="Phone"
required
style="width:140px; flex-shrink:0;"
/>
<input type="tel" name="phone_number" class="form-control">
</div>
{{-- Country / nationality --}}
<x-country-select
name="nationality"
label="Nationality"
placeholder="Select nationality"
value="{{ old('nationality', $user->nationality) }}"
required
/>
{{-- Timezone --}}
<x-timezone-select
name="timezone"
label="Timezone"
value="{{ old('timezone', $user->timezone ?? 'Asia/Bahrain') }}"
required
/>
{{-- Language --}}
<x-language-select
name="language"
label="Language"
placeholder="Select language"
value="{{ old('language', $video->language ?? '') }}"
required
/>
```
---
## `<x-share-modal>` &amp; `<x-share-button>`
**Files:** `resources/views/components/share-modal.blade.php` (singleton modal + `openShareModal()` JS), `resources/views/components/share-button.blade.php` (trigger).
**Rule:** The only sanctioned way to share. `<x-share-modal />` is rendered once in `layouts/app.blade.php`; every share entry point uses `<x-share-button :video="$video" />`. Never duplicate the modal or hand-write `openShareModal(...)` triggers. `<x-share-button>` props: `video` (required), `tag` (`button`|`a`); extra attributes forwarded; slot overrides the label. Offers copy-link, social, send-by-email, and share-to-members (notification + email).
| View file | Usage | Notes |
|---|---|---|
| `resources/views/layouts/app.blade.php` | `<x-share-modal />` | Singleton, rendered once for the whole app layout |
| `resources/views/components/video-card.blade.php` | `<x-share-button :video tag="a" class="dropdown-item">` | Home/listing card 3-dot menu |
| `resources/views/videos/show.blade.php` | `<x-share-button :video class="yt-action-btn">` + `videoShare()` passes full args | Watch page (mobile + desktop share) |
| `resources/views/videos/partials/video-details.blade.php` | `<x-share-button :video class="action-btn">` | Watch-page details share button |
| `resources/views/components/video-actions.blade.php` | `shareCurrent(...)``openShareModal(...)` | Main watch-page share; passes email + members URLs |
**Known not-yet-migrated:** `resources/views/videos/shorts.blade.php` (JS feed share, partial args) and `resources/views/playlists/show.blade.php` (playlists have no email/members endpoints — video-only feature). Migrate shorts when touched.
---
## Modification checklist
When you modify any of these components, work through this list:
- [ ] Update `app/Data/Countries.php` if the data structure changes
- [ ] Update all three `.blade.php` component files if shared CSS/JS changes
- [ ] Update the `for*()` method in `Countries.php` that feeds the changed component
- [ ] Re-test every page listed in the usage tables above
- [ ] Add/remove rows from the usage tables if views were added or removed

View File

@ -1,5 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@ -57,14 +57,3 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# NAS File Manager
NAS_PROTOCOL=smb
NAS_HOST=
NAS_PORT=445
NAS_USERNAME=
NAS_PASSWORD=
NAS_PATH=/media
NAS_SMB_SHARE=
NAS_SMB_DOMAIN=
NAS_FM_ROUTE_PREFIX=nas-file-manager

12
.gitignore vendored
View File

@ -3,7 +3,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/data/*.key /storage/*.key
/vendor /vendor
.env .env
.env.backup .env.backup
@ -17,13 +17,3 @@ yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.vscode /.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

415
CLAUDE.md
View File

@ -1,415 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**TAKEONE** (Play) is a Laravel 10 video-sharing platform with sports match annotation, HLS adaptive streaming, GPU-accelerated video processing, playlists, threaded comments, and a super-admin panel. Live at `https://video.takeone.bh`.
Tech stack: PHP 8.1+, Laravel 10, Blade templating, Vite 5, Axios, FFmpeg/FFProbe with NVIDIA NVENC, SQLite (dev) / MySQL (prod), Laravel Sanctum.
---
## Essential Commands
### Local Development
```bash
php artisan serve # Backend on http://localhost:8000
npm run dev # Vite dev server with HMR
npm run build # Production frontend build → public/build/
```
### Database
```bash
php artisan migrate
php artisan db:seed
php artisan tinker
```
### Background Workers
```bash
# Video processing (CompressVideoJob, GenerateHlsJob)
php artisan queue:work --queue=video-processing
# Orphaned file cleanup scheduler (every CLEANUP_INTERVAL_MINUTES, default 30)
php artisan schedule:run # run this every minute via cron
php artisan cleanup:orphaned-videos --dry-run # preview only
php artisan cleanup:orphaned-videos --force # delete orphans
```
### Testing
```bash
./vendor/bin/phpunit
./vendor/bin/phpunit --filter "TestName"
./vendor/bin/phpunit tests/Feature/ExampleTest.php
```
Tests use in-memory SQLite, array mail/cache/session drivers, sync queue, and BCRYPT_ROUNDS=4. Set `APP_ENV=testing`.
---
## Architecture
### Role System
Three roles stored on `users.role`: `super_admin`, `admin`, `user` (null = user). The `IsSuperAdmin` middleware guards all `/admin/*` routes. Helper methods on the User model: `isSuperAdmin()`, `isAdmin()`, `isUser()`.
### Video Lifecycle
1. `VideoController@store` validates upload, stores file, extracts metadata via FFProbe (duration, dimensions, orientation).
2. Status column transitions: `pending → processing → ready` (or `failed`).
3. `CompressVideoJob` re-encodes with NVIDIA NVENC (`h264_nvenc`, CRF 23), replaces original if smaller.
4. `GenerateHlsJob` produces 480p / 720p / 1080p HLS variants (`h264_nvenc`, preset p4) as `.m3u8` + `.ts` files.
5. Streaming served at `GET /videos/{video}/hls/{file?}` (master playlist → segments).
Queue connection is `sync` by default (runs inline); switch `QUEUE_CONNECTION=database` or `redis` for true async.
### Shorts Auto-Detection
A video is a Short when duration ≤ 60 s **and** portrait orientation. The `is_shorts` DB column allows manual override. Use model scopes `Video::shorts()` / `Video::notShorts()`.
### Trending Algorithm
`Video::scopeTrending($hours=48, $limit=50)` — weighted score: 70% recent views, 15% view velocity, 10% recency bonus, 5% likes. Only applies to videos < 10 days old with 5 recent views.
### Playlist System
- `playlist_videos` pivot carries `position`, `watched_seconds`, `watched`, `added_at`, `last_watched_at`.
- Each user has one `is_default = true` playlist ("Watch Later").
- `Playlist` model methods: `addVideo()`, `removeVideo()`, `reorder()`, `getNextVideo()`, `getPreviousVideo()`, `updateWatchProgress()`, `canView()`, `canEdit()`.
### Comment Threading & Mentions
Comments have a `parent_id` for one-level threading (replies). The `parsed_body` accessor converts `@username` syntax into clickable profile links.
### Comment Timestamp Badges
The `enhanceBody()` function in `components/video-comments.blade.php` converts timestamp syntax written in comments into clickable `._comment-time-badge` spans at render time (client-side). Supported formats:
- `@mm:ss` — single timestamp, jumps to that point (colon separator)
- `@mm.ss` — same, dot separator (legacy, still supported)
- `@mm:ss-mm:ss` or `@mm.ss-mm.ss` — time range; plays from start to end then pauses
Clicking a badge: scrolls to `#ytpWrap` (smooth), waits 500 ms, seeks `#videoPlayer` to `start`, calls `.play()`, and if a range is specified stops at `end` via a `timeupdate` listener. `@username` mention detection requires the first char to be a letter so it never collides with numeric timestamps.
### Sports Match Annotation
Videos of type `match` support three related models:
- `MatchRound` — round number, name, `start_time_seconds`
- `MatchPoint``timestamp_seconds`, action, competitor (blue/red), running score
- `CoachReview` — time-range segment with coach note and emoji
All managed via `MatchEventController` under authenticated routes.
### Key Model Scopes & Accessors
`Video` scopes: `public()`, `visibleTo($user)`, `shorts()`, `notShorts()`, `trending()`.
`Video` accessors: `url`, `thumbnail_url`, `like_count`, `view_count`, `formatted_duration`, `iso_duration`, `open_graph_image`.
`Playlist` accessors: `thumbnail_url`, `video_count`, `total_duration`, `formatted_duration`.
## Rules
**Never navigate between videos with a page refresh** — all video-to-video transitions (Up Next recommendations, playlist tracks, prev/next) must use JavaScript SPA transitions. Never use `window.location.href` or `<a>` tags with hard navigation for video card clicks. The established pattern is:
- **Video player (generic, match types):** `recTransitionTo(url)` / `plTransitionTo(url)` — fetch `/videos/{key}/player-data` JSON, call `window._ytpLoadSource(hlsUrl, mp4Url)`, then `recSwapContent(url)` / `plSwapContent(url)` in the background to update description, comments, and sidebar.
- **Audio player (music type):** same pattern but swap `audio.src` and `audio.play()` instead of `_ytpLoadSource`.
- **Sidebar cards** must have `data-rec-url` (Up Next) or `data-pl-id` (playlist) attributes and call `recGoTo(url)` / `plGoTo(url)` onclick — never `window.location.href`.
- **Autoplay on track end** is wired via `window._plOnVideoEnd` (video) or `window._plOnTrackEnd` (audio) hooks — the player calls these hooks on `ended`; the SPA script sets them.
- The only fallback to `window.location.href` is inside `catch` blocks when the fetch itself fails.
**Database changes require confirmation** — if any task requires a migration, schema change, or new column, always ask before proceeding.
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.
**All buttons must use the global `.action-btn` system** — never add custom button CSS. Use these classes:
- `.action-btn` — default (bordered, bg-secondary)
- `.action-btn.action-btn-primary` or `.action-btn.primary` — brand red, for primary/submit actions
- `.action-btn.action-btn-danger` or `.action-btn.danger` — red border/text, for destructive actions
- `.action-btn.icon-only` — square padding, for icon-only buttons
Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</span></button>`. The global CSS lives in `layouts/app.blade.php`.
**Mobile layout uses a native-app scroll model** — on `max-width: 768px`, `html` and `body` are locked (`overflow: hidden; position: fixed`) so the window never scrolls. `.yt-main` is `position: fixed` spanning `top: 56px` to `bottom: calc(56px + env(safe-area-inset-bottom))` with `overflow-y: auto; -webkit-overflow-scrolling: touch`. This keeps the header and bottom nav truly fixed without any JavaScript. Consequences to remember:
- **Never use `position: sticky` inside `.yt-main` on mobile** — sticky elements float over content because the scroll container changed. Override with `position: relative !important` in the mobile media query.
- **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.
**Never build custom dropdowns for country, nationality, phone code, timezone, currency, or language** — reusable Blade components already exist for these. Always use them; never roll a new `<select>`, inline list, or custom picker:
| Need | Component | Stored value |
|---|---|---|
| Country / nationality picker | `<x-country-select name="…" />` | ISO2 code e.g. `"BH"` |
| Phone / dial-code picker | `<x-phone-code-select name="…" />` | `"+973|BH"` — split on `|` to get code alone |
| Timezone picker | `<x-timezone-select name="…" />` | IANA string e.g. `"Asia/Bahrain"` |
| Currency | read from `App\Data\Countries::all()[$iso2]['currency']` | ISO 4217 code e.g. `"BHD"` |
| Language picker | `<x-language-select name="…" />` | ISO 639-1 code e.g. `"ar"`, `"en"` |
All four select components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. Country/phone/timezone data lives in `app/Data/Countries.php`; language data lives in `app/Data/Languages.php`. Usage is tracked in `.claude/component-usage.md` — add a row to the relevant table whenever you place one of these components in a view.
**All sharing must go through the share component — never build a custom share UI or duplicate the modal.** There is exactly one share modal, and one way to trigger it:
| Need | Use | Notes |
|---|---|---|
| The share modal itself | `<x-share-modal />` | Singleton — already rendered once in `layouts/app.blade.php`. Never copy its markup or `@include` it again on a page that uses the app layout. Lives in `resources/views/components/share-modal.blade.php`. |
| A share button / menu item | `<x-share-button :video="$video" />` | Pass `tag="a"` for dropdown items (e.g. video-card menu), default `tag="button"` for `.action-btn`. Extra classes/attributes are forwarded; a slot overrides the default "Share" label. |
The button calls the global `openShareModal(shareUrl, title, recordUrl, emailUrl, membersUrl)`, which provides copy-link, social, **send-by-email**, and **share-to-members** (in-app notification + email) in one place. Rules that must never be violated:
1. **Never render a raw share `<a onclick="openShareModal(...)">`** — use `<x-share-button>` so every entry point passes the full argument set.
2. **If you must call `openShareModal()` from JS, pass all five arguments** — including `route('videos.shareEmail', $video)` and `route('videos.shareMembers', $video)` (empty string for guests). Calling it with partial args silently drops the email/members options.
3. **Never build a second share modal, dropdown, or sheet** anywhere. New share entry points reuse `<x-share-button>`.
4. **Share-to-members** is powered by `VideoController@shareWithMembers` + the `VideoSharedWithUser` notification (database + mail) and the `users.search` typeahead — extend these rather than adding a parallel path.
**Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception:
1. **Creating a new reusable component** → add a new section to the tracker listing the component file path, its props, and an empty usage table.
2. **Placing a component in any view** → immediately add a row to the relevant tracker table (view file path, field/slot name, any relevant notes). Do this in the same task, not later.
3. **Modifying a component** (props, markup, CSS, JS, behaviour) → open the tracker first, read every row in that component's usage table, then apply the necessary follow-up changes to every listed view before marking the task done. Never modify a component without checking its tracker entries.
4. **Removing a component from a view** → delete its row from the tracker table in the same task.
5. **Deleting a component entirely** → remove its full section from the tracker and clean up every view that was still referencing it.
The tracker is the source of truth for blast radius. If the tracker is out of date and a change breaks an unlisted page, that is a process failure — always keep it accurate.
**Always use the self-hosted flag-icons library for every flag in the project — never use emoji flags or external CDN flag sources.**
The flag-icons v7.2.3 library is self-hosted at `public/vendor/flag-icons/` (CSS + 270 SVG files). It is loaded synchronously in both layouts:
- Front-end: `resources/views/layouts/app.blade.php` via `asset('vendor/flag-icons/css/flag-icons.min.css')`
- Admin: `resources/views/admin/layout.blade.php` via the same asset path
Rules that must never be violated:
1. **Never use emoji flags** (`🇧🇭`, `🇺🇸`, etc.) anywhere — they are invisible on Windows Chrome/Firefox. Always use `<span class="fi fi-{iso2}"></span>` where `{iso2}` is a lowercase two-letter country code (e.g. `bh`, `us`, `gb`).
2. **Never load flag-icons from a CDN** (`jsdelivr`, `unpkg`, `flagcdn.com`, etc.). The library is already self-hosted; adding a CDN link creates a duplicate load and a network dependency.
3. **`Countries::all()` `flag` field is a lowercase ISO2 code** — `app/Data/Countries.php` generates this via `$f = fn(string $c) => strtolower($c)`. Do not change it back to emoji. Every component that renders `$opt['flag']` already wraps it in `<span class="fi fi-{{ $opt['flag'] }}"></span>`.
4. **`Languages::all()` `flag` field is also a lowercase ISO2 code** — e.g. Arabic → `'sa'`, English → `'gb'`. Render it the same way.
5. **When updating JS that copies a selected flag into a button icon**, always use `innerHTML`, not `textContent` — the flag is now an HTML `<span>`, not a text character. The `_pick()` method in the custom-select components already does this.
6. **For the admin chart flag overlays** (`admin/dashboard.blade.php`), use `<span class="fi fi-{code}">` elements positioned absolutely — not `<img>` tags from `flagcdn.com`.
7. **Unknown/missing country fallback**: always use `<span class="fi fi-xx"></span>` — the library's own built-in placeholder (the SVG exists at `public/vendor/flag-icons/flags/4x3/xx.svg`). Never use any external icon, emoji, or Bootstrap Icons globe as a fallback. In Blade: `<span class="fi fi-{{ $flag ?: 'xx' }}"></span>`. In JS: `` `<span class="fi fi-${flag || 'xx'}"></span>` ``.
**Match highlights sidebar must always match the video player height** — use a `ResizeObserver` on `#ytpWrap` to write `--sidebar-height` to `document.documentElement` and bind `.events-sidebar { height: var(--sidebar-height) }`. Never hardcode a pixel or viewport height for the sidebar. The pattern lives in `videos/types/match.blade.php` (`initSidebarHeightSync`).
### NAS with automatic local fallback
**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 `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)
├── playlists/
│ └── {playlist-id}/
│ └── thumb.{ext}
├── posts/
│ └── {post-id}/
│ └── {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 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()`):**
- **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 `data/app/`) | Served via |
|---|---|---|
| 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/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.
**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:
- `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.
**Absolute rules — these must never be violated:**
1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.
2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
- Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
- Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
- Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
- Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
- Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
- Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`
3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.
4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.
5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.
6. **Always check `NasSyncService::isEnabled()` before doing a NAS operation.** It returns `false` when NAS is unreachable (TCP port-445 check, cached 2 minutes) or when the setting is disabled. Code with `if ($nas->isEnabled())` branches that fall back to local storage is correct — the `nas:auto-sync` scheduler will migrate local files to NAS when it comes back online.
**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
3. Delete files with no DB match (orphans) directly with `@unlink()`
4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)
### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
- **Broadcasting**: Pusher is configured but `BroadcastServiceProvider` is commented out — not active.
- **Timezone**: `Asia/Bahrain` (set in `config/app.php`).
- **App name constant**: `config('app.name')` returns `TAKEONE`.
### Route Structure Summary
- Public: `/`, `/videos`, `/trending`, `/shorts`, `/videos/search`, `/videos/{video}`, stream/hls/download
- Auth-required: video CRUD, likes, comments, profile, settings, history, playlists, match events
- Admin (`/admin/*`, `super_admin` middleware): dashboard, user CRUD, video CRUD, orphan cleanup
- API: `GET /api/user` (Sanctum token auth)

View File

@ -1,27 +0,0 @@
# Video Platform Orphan Cleanup ✅
## Core Feature Complete
**Cron Job:**
- Command: `php artisan cleanup:orphaned-videos --force`
- Scheduled: Every 30min (`CLEANUP_INTERVAL_MINUTES=30` in .env)
- Dir: Deletes `storage/app/public/videos/*` w/o DB Video::filename match (inc compressed_)
- Logs: `storage/logs/orphaned-videos.log`
**Dashboard (/admin/dashboard):**
- Gauge: Videos size %
- Button: AJAX manual clean
**Production Setup:**
```
* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1
```
**Tested:**
- Dry-run: Found/deleted 6 orphans
- Cron: Scheduled
- Logs: Active
Button CSRF issue? Use CLI manual or cron.
Self-cleaning platform ready. 🎥🧹

35
TODO.md Normal file
View File

@ -0,0 +1,35 @@
# Video Platform Enhancement Tasks - COMPLETED
## Phase 1: Database & Backend ✅
- [x] Create comments migration table
- [x] Create Comment model
- [x] Create CommentController
- [x] Add routes for comments
- [x] Update Video model with subscriber count
## Phase 2: Video Type Views ✅
- [x] Update generic.blade.php with video type icon and enhanced channel info
- [x] Update music.blade.php with video type icon and enhanced channel info
- [x] Update match.blade.php with video type icon and enhanced channel info
## Phase 3: Comment Section ✅
- [x] Add comment section UI to video views
- [x] Add @ mention functionality
## Features Implemented:
1. Video type icons in red color before title:
- music → 🎵 (bi-music-note)
- match → 🏆 (bi-trophy)
- generic → 🎬 (bi-film)
2. Enhanced channel info below title:
- Channel picture
- Channel name
- Number of subscribers
- Number of views
- Like button with icon and count
- Edit & Share buttons
3. Comment section:
- Users can comment on videos
- @ mention support to mention other users/channels

View File

@ -1,102 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Video;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CleanupOrphanedVideos extends Command
{
protected $signature = 'cleanup:orphaned-videos {--dry-run : List orphans without deleting (default)} {--force : Actually delete orphans}';
protected $description = 'Remove orphaned video files not linked to any database record';
public function handle()
{
$dryRun = $this->option('dry-run') !== false;
$force = $this->option('force');
if (!$dryRun && !$force) {
$this->warn('Use --dry-run (default) to preview, or --force to delete.');
return 1;
}
$this->info($dryRun ? 'DRY RUN MODE - No files will be deleted.' : 'FORCE MODE - Deleting orphaned files.');
$disk = Storage::disk('public');
$videoDir = 'videos';
$files = $disk->files($videoDir);
if (empty($files)) {
$this->info('No video files found in storage/app/public/videos/');
return 0;
}
// Get all valid filenames from DB (exact match for filename column)
$dbFilenames = Video::pluck('filename')->filter()->toArray();
$orphans = [];
foreach ($files as $file) {
// Only video files (skip non-videos)
if (!str_ends_with($file, '.mp4') && !str_ends_with($file, '.webm') && !str_ends_with($file, '.mov')) {
continue;
}
$basename = basename($file);
// Check if exact match or compressed_ prefix with base in DB
$isOrphan = true;
if (in_array($basename, $dbFilenames)) {
$isOrphan = false;
} elseif (str_starts_with($basename, 'compressed_')) {
$originalBasename = substr($basename, 10); // remove 'compressed_'
if (in_array($originalBasename, $dbFilenames)) {
$isOrphan = false;
}
}
if ($isOrphan) {
$orphans[] = $file;
}
}
$totalFiles = count($files);
$orphanCount = count($orphans);
$this->table(
['Stat', 'Value'],
[
['Total video files scanned', $totalFiles],
['Orphaned files found', $orphanCount],
]
);
if ($orphanCount === 0) {
$this->info('No orphaned videos found! ✅');
Log::channel('orphaned-videos')->info('Cleanup run: 0 orphans found.');
return 0;
}
if ($dryRun) {
$this->table(['Orphan Files (would delete)'], array_map(fn($f) => [ $f ], $orphans));
Log::channel('orphaned-videos')->info('DRY RUN: Found ' . $orphanCount . ' orphans', ['files' => $orphans]);
$this->warn("Run with --force to delete these files.");
} else {
$bar = $this->output->createProgressBar($orphanCount);
$bar->start();
foreach ($orphans as $orphan) {
$disk->delete($orphan);
Log::channel('orphaned-videos')->info('Deleted orphan: ' . $orphan);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("✅ Deleted {$orphanCount} orphaned video files.");
}
return 0;
}
}

View File

@ -1,61 +0,0 @@
<?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;
}
}

View File

@ -1,241 +0,0 @@
<?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);
}
}
}
}

View File

@ -1,75 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\NasSyncVideoJob;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
class NasAutoSync extends Command
{
protected $signature = 'nas:auto-sync';
protected $description = 'Push locally-stored files to NAS when NAS is available. Runs as a scheduled task.';
public function handle(NasSyncService $nas): int
{
if (! $nas->isEnabled()) {
return 0; // NAS down or disabled — nothing to do
}
// Videos whose file OR thumbnail/slides are still on local disk
$synced = 0;
Video::with(['user', 'slides'])
->where('status', 'ready')
->where('path', 'like', 'users/%')
->get()
->each(function (Video $video) use ($nas, &$synced) {
$hasLocalVideo = file_exists(storage_path('app/' . $video->path));
// Check for locally-stored thumbnail (NAS push failed during edit)
$hasLocalThumb = $video->thumbnail
&& str_starts_with($video->thumbnail, 'users/')
&& file_exists(storage_path('app/' . $video->thumbnail));
// Check for locally-stored slides
$hasLocalSlide = false;
foreach ($video->slides as $slide) {
if (str_starts_with($slide->filename, 'users/')
&& file_exists(storage_path('app/' . $slide->filename))) {
$hasLocalSlide = true;
break;
}
}
if ($hasLocalVideo || $hasLocalThumb || $hasLocalSlide) {
NasSyncVideoJob::dispatch($video);
$synced++;
}
});
// Avatars and banners stuck locally
User::whereNotNull('avatar')->orWhereNotNull('banner')->get()
->each(function (User $user) use ($nas) {
if ($user->avatar
&& str_starts_with($user->avatar, 'users/')
&& file_exists(storage_path('app/' . $user->avatar))) {
$nas->syncAvatar($user, storage_path('app/' . $user->avatar));
$nas->deleteLocalAvatar($user);
}
if ($user->banner
&& str_starts_with($user->banner, 'users/')
&& file_exists(storage_path('app/' . $user->banner))) {
$nas->syncCover($user, storage_path('app/' . $user->banner));
$nas->deleteLocalBanner($user);
}
});
if ($synced > 0) {
$this->info("Queued {$synced} video(s) for NAS sync.");
}
return 0;
}
}

View File

@ -1,369 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class NasFreeLocalStorage extends Command
{
protected $signature = 'nas:free-local
{--dry-run : Preview what would be deleted without deleting}
{--force : Actually delete local files confirmed on NAS}
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
public function handle(NasSyncService $nas): int
{
if (! $nas->isEnabled()) {
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
return 1;
}
$dryRun = $this->option('dry-run');
$force = $this->option('force');
if (! $dryRun && ! $force) {
$this->warn('Pass --dry-run to preview, or --force to delete.');
return 1;
}
$mode = $dryRun ? 'DRY RUN — nothing will be deleted' : 'FORCE — deleting confirmed NAS files';
$this->info($mode);
$this->newLine();
$totalBytes = 0;
$toDelete = [];
// ── Videos ───────────────────────────────────────────────────────────
$this->info('Scanning video files…');
$videos = Video::all()->filter(fn (Video $v) => file_exists(storage_path('app/' . $v->path)));
if ($videos->isNotEmpty()) {
$bar = $this->output->createProgressBar($videos->count());
$bar->start();
foreach ($videos as $video) {
$localPath = storage_path('app/' . $video->path);
$dir = $nas->resolveVideoDir($video);
$meta = null;
try {
$raw = $nas->getContent("{$dir}/meta.json");
$meta = $raw ? json_decode($raw, true) : null;
} catch (\Throwable) {}
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
$bytes = filesize($localPath);
$totalBytes += $bytes;
$toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes];
}
$bar->advance();
}
$bar->finish();
$this->newLine();
} else {
$this->line(' No local video files found.');
}
// ── Thumbnails & slides (both legacy flat dir and new NAS-mirrored dirs) ──
$this->newLine();
$this->info('Scanning thumbnail and slide files…');
// Helper: check if a video's NAS dir is confirmed, using meta.json
$confirmNas = function (Video $v) use ($nas): bool {
try {
$raw = $nas->getContent($nas->resolveVideoDir($v) . '/meta.json');
$meta = $raw ? json_decode($raw, true) : null;
return is_array($meta) && ($meta['id'] ?? null) === $v->id;
} catch (\Throwable) { return false; }
};
// Legacy flat thumbnails dir
$thumbDir = storage_path('app/public/thumbnails');
if (is_dir($thumbDir)) {
foreach (glob($thumbDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "thumb:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
} else {
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "slide:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
}
// New NAS-mirrored dirs: storage/app/users/{username}/videos/{slug}/thumb.*
// slides/{id}.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($usersBase, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$absPath = $file->getPathname();
$relPath = ltrim(str_replace(storage_path('app'), '', $absPath), '/');
// Match thumbnail or slide by relative path stored in DB
$video = Video::where('thumbnail', $relPath)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'thumb:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
continue;
}
$slide = \App\Models\VideoSlide::where('filename', $relPath)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'slide:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning thumbnails.');
// ── Avatars ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning avatar files…');
// Legacy flat dir
$avatarDir = storage_path('app/public/avatars');
if (is_dir($avatarDir)) {
foreach (glob($avatarDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('avatar', $filename)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
// New structured dir: users/{slug}/profile/avatar.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
if (! is_file($file)) continue;
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
$user = User::where('avatar', $relPath)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => 'avatar:' . basename($file), 'path' => $file, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning avatars.');
// ── Banners ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning banner files…');
// Legacy flat dir
$bannerDir = storage_path('app/public/banners');
if (is_dir($bannerDir)) {
foreach (glob($bannerDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('banner', $filename)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
// New structured dir: users/{slug}/profile/cover.*
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
if (! is_file($file)) continue;
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
$user = User::where('banner', $relPath)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => 'banner:' . basename($file), 'path' => $file, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning banners.');
// ── Generated/derived renders inside song folders ─────────────────────
// Everything under a song's cache/ subfolder (download videos + HLS) is a
// render that regenerates on demand, so it is always safe to delete to free
// space. Sources (audio, tracks, slides) live outside cache/ and are untouched.
$this->newLine();
$this->info('Scanning generated renders (song cache/ folders)…');
$usersRoot = storage_path('app/users');
if (is_dir($usersRoot)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$path = $file->getPathname();
if (! str_contains($path, '/cache/')) continue;
$bytes = $file->getSize();
$totalBytes += $bytes;
$toDelete[] = ['label' => str_contains($path, '/cache/hls/') ? 'hls' : 'download-video', 'path' => $path, 'bytes' => $bytes];
}
$this->line(' Done scanning generated renders.');
}
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
// These are on-demand local copies of NAS videos used for HTTP streaming.
// Always safe to delete — they are re-downloaded from NAS on next play.
$this->newLine();
$this->info('Scanning NAS stream cache…');
$nasCacheDir = storage_path('app/nas_cache/videos');
$ttl = (int) $this->option('cache-ttl');
$cutoff = time() - ($ttl * 3600);
if (is_dir($nasCacheDir)) {
foreach (glob("{$nasCacheDir}/*") as $file) {
if (! is_file($file)) continue;
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
}
$this->line(' Done scanning NAS stream cache.');
}
// ── Summary ───────────────────────────────────────────────────────────
$this->newLine();
if (empty($toDelete)) {
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
return 0;
}
$this->table(
['Type', 'File', 'Size'],
array_map(fn ($row) => [
$row['label'],
basename($row['path']),
$this->humanBytes($row['bytes']),
], $toDelete)
);
$this->newLine();
$this->line(sprintf(
'Found <comment>%d</comment> file(s) totalling <comment>%s</comment> that can be freed.',
count($toDelete),
$this->humanBytes($totalBytes)
));
if ($dryRun) {
$this->newLine();
$this->warn('Run with --force to delete these files.');
return 0;
}
// ── Actually delete ───────────────────────────────────────────────────
$deleted = 0;
$failed = 0;
foreach ($toDelete as $row) {
if (@unlink($row['path'])) {
$deleted++;
Log::info('nas:free-local: deleted ' . $row['path']);
} else {
$failed++;
$this->warn("Could not delete: {$row['path']}");
}
}
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
$this->pruneEmptyDirs(storage_path('app/users'));
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
$path = storage_path("app/{$rel}");
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
@rmdir($path);
}
}
$this->newLine();
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
if ($failed > 0) {
$this->warn("{$failed} file(s) could not be deleted — check permissions.");
}
return 0;
}
/**
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
*/
private function pruneEmptyDirs(string $root): void
{
if (! is_dir($root)) return;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function humanBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
return $bytes . ' B';
}
}

View File

@ -1,435 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class NasRepairLocalFiles extends Command
{
protected $signature = 'nas:repair
{--dry-run : Preview what would be pushed without making changes}
{--force : Actually upload to NAS and remove local copies}
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
protected $description = 'Upload stuck local files to NAS, fix DB paths, and delete local copies';
private NasSyncService $nas;
public function handle(NasSyncService $nas): int
{
if (! $nas->isEnabled()) {
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
return 1;
}
$this->nas = $nas;
$dryRun = $this->option('dry-run');
$force = $this->option('force');
if (! $dryRun && ! $force) {
$this->warn('Pass --dry-run to preview, or --force to repair.');
return 1;
}
$this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies');
$this->newLine();
$totalRepaired = 0;
$totalFailed = 0;
// ── NAS-format videos / slides / thumbnails ───────────────────────────
$stuckVideos = $this->findStuckVideos();
if ($stuckVideos->isNotEmpty()) {
$this->info('Video / slide files stuck locally:');
$rows = [];
foreach ($stuckVideos as $item) {
foreach ($item['files'] as $label) {
$rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label];
}
}
$this->table(['Video ID', 'Title', 'File'], $rows);
$this->newLine();
if ($force) {
foreach ($stuckVideos as $item) {
$video = $item['video'];
$this->line(" Repairing video #{$video->id}: {$video->title}");
try {
$nas->syncVideo($video);
$nas->deleteLocalAssets($video);
if ($video->hls_path || $video->type === 'music') {
$nas->deleteLocalVideo($video);
}
$nas->pruneLocalVideoDir($video);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
Log::info("nas:repair: fixed video #{$video->id}");
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Avatars ───────────────────────────────────────────────────────────
$stuckAvatars = $this->findStuckAvatars();
if ($stuckAvatars->isNotEmpty()) {
$this->info('Avatar files stuck locally:');
$this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckAvatars as $item) {
$user = $item['user'];
$path = $item['path'];
$this->line(" Repairing avatar for {$user->username}");
try {
$nas->syncAvatar($user, $path);
$nas->deleteLocalAvatar($user);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Banners ───────────────────────────────────────────────────────────
$stuckBanners = $this->findStuckBanners();
if ($stuckBanners->isNotEmpty()) {
$this->info('Banner files stuck locally:');
$this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckBanners as $item) {
$user = $item['user'];
$path = $item['path'];
$this->line(" Repairing banner for {$user->username}");
try {
$nas->syncCover($user, $path);
$nas->deleteLocalBanner($user);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Legacy flat thumbnails (public/thumbnails/) ───────────────────────
$stuckThumbs = $this->findStuckLegacyThumbnails();
if ($stuckThumbs->isNotEmpty()) {
$this->info('Legacy thumbnail/slide files stuck locally:');
$this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [
$r['type'], $r['file'], $r['video_id'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckThumbs as $item) {
$this->line(" Repairing {$item['type']}: {$item['file']}");
try {
if ($item['type'] === 'thumbnail' && $item['video']) {
$nas->syncVideo($item['video']);
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
// Upload slide directly to NAS
$video = $item['video'];
$slide = $item['slide'];
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
$nas->mkdirp("{$dir}/slides");
$nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}");
}
@unlink($item['path']);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
}
}
$this->newLine();
}
}
$anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty()
|| $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty();
// ── NAS orphaned video folders ─────────────────────────────────────────
$this->newLine();
$this->info('Scanning NAS for orphaned video folders (no matching DB record)…');
$nasOrphans = $nas->scanNasOrphans();
if (! empty($nasOrphans)) {
$this->table(
['NAS Directory', 'meta.json video_id'],
array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans)
);
$this->newLine();
if ($force) {
$deletedOrphans = 0;
$failedOrphans = 0;
foreach ($nasOrphans as $orphan) {
$this->line(" Deleting NAS orphan: {$orphan['dir']}");
try {
$nas->deleteNasTree($orphan['dir']);
$deletedOrphans++;
$this->line(' <info>✓ Deleted</info>');
Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
} catch (\Throwable $e) {
$failedOrphans++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
}
}
$totalRepaired += $deletedOrphans;
$totalFailed += $failedOrphans;
$this->newLine();
}
} else {
$this->line(' No orphaned NAS folders found.');
}
$anyIssues = $anyStuck || ! empty($nasOrphans);
// ── NAS stream cache ──────────────────────────────────────────────────
$cacheBytes = $nas->nasCacheSize();
if ($cacheBytes > 0) {
$ttl = (int) $this->option('cache-ttl');
$ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h";
$this->info(sprintf(
'NAS stream cache: <comment>%s</comment> occupying <comment>%s</comment> — will be evicted on --force (%s).',
count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)',
$this->humanBytes($cacheBytes),
$ttlLabel
));
$this->newLine();
}
if (! $anyIssues && $cacheBytes === 0) {
$this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.');
} elseif (! $anyIssues) {
$this->info('No issues found (stream cache will be evicted on --force).');
}
if ($dryRun && ($anyIssues || $cacheBytes > 0)) {
$this->warn('Run with --force to repair.');
return 0;
}
if ($force) {
$this->pruneAllLocalDirs();
$ttl = (int) $this->option('cache-ttl');
$evicted = $nas->clearNasCache($ttl);
if ($evicted > 0) {
$this->line("Evicted <comment>{$evicted}</comment> NAS stream-cache file(s).");
}
}
if ($totalRepaired > 0 || $force) {
$this->newLine();
if ($totalFailed === 0) {
$this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up.");
} else {
$this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details.");
}
}
return $totalFailed > 0 ? 1 : 0;
}
// ── Scanners ──────────────────────────────────────────────────────────────
private function findStuckVideos(): \Illuminate\Support\Collection
{
return Video::with(['user', 'slides'])->get()
->filter(fn (Video $v) => str_starts_with($v->path, 'users/'))
->map(function (Video $video) {
$files = [];
if (file_exists(storage_path('app/' . $video->path)))
$files[] = basename($video->path) . ' (video)';
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
file_exists(storage_path('app/' . $video->thumbnail)))
$files[] = basename($video->thumbnail) . ' (thumbnail)';
foreach ($video->slides as $slide) {
if (file_exists($slide->localPath()))
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
}
return $files ? ['video' => $video, 'files' => $files] : null;
})
->filter();
}
private function findStuckAvatars(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir: public/avatars/
$dir = storage_path('app/public/avatars');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = User::where('avatar', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/avatar.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
// relPath = "users/{slug}/profile/avatar.{ext}"
$user = User::where('avatar', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function findStuckBanners(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir: public/banners/
$dir = storage_path('app/public/banners');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = User::where('banner', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/cover.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
$user = User::where('banner', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection
{
$dir = storage_path('app/public/thumbnails');
if (! is_dir($dir)) return collect();
$results = [];
foreach (scandir($dir) ?: [] as $filename) {
if ($filename === '.' || $filename === '..') continue;
$path = "{$dir}/{$filename}";
if (! is_file($path)) continue;
// Check if it's a video thumbnail
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
continue;
}
// Check if it's an old-format slide
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
}
}
return collect($results);
}
// ── Directory pruning ─────────────────────────────────────────────────────
private function pruneAllLocalDirs(): void
{
// NAS-mirrored dirs
$this->pruneEmptyDirTree(storage_path('app/users'));
// Flat asset dirs — remove if empty
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
$path = storage_path("app/{$rel}");
if (is_dir($path) && $this->isDirEmpty($path)) {
@rmdir($path);
}
}
}
/**
* Bottom-up prune: remove dirs that contain only meta.json or nothing.
*/
private function pruneEmptyDirTree(string $root): void
{
if (! is_dir($root)) return;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
private function isDirEmpty(string $dir): bool
{
return empty(array_diff(scandir($dir) ?: [], ['.', '..']));
}
private function humanBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
return $bytes . ' B';
}
}

View File

@ -1,325 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Major file-structure cleanup for audio "songs":
*
* 1. CONSOLIDATE every song's files into the song's own folder. Each file is
* gathered from wherever its local copy currently is (the canonical path OR
* the nas_cache/) and moved to:
* - primary : kept at its canonical name; promoted/legacy primaries are
* relocated to {song-folder}/{title-slug}.{ext}
* - secondary: {song-folder}/{folder-slug}-{lang}-{track-id}.{ext}
* (lowercase, unique the db id means nothing can ever overwrite anything,
* and there is no more tracks/ subfolder).
* 2. UPDATE the DB records (path + filename) to match.
* 3. DELETE orphan files (referenced by NO database column) and empty folders
* across the media roots.
*
* Moves are two-phase (src -> temp -> final) so the historical primary<->secondary
* "swaps" cannot clobber each other. Serving works straight afterwards because
* NasSyncService::ensureLocalCopy() prefers the local canonical path.
*
* Defaults to a DRY RUN. Pass --force to apply.
*/
class ReorganizeAudioTracks extends Command
{
protected $signature = 'tracks:reorganize {--force : Apply changes (default is a dry run)}';
protected $description = 'Consolidate audio tracks into one folder per song with unique names, update records, delete orphans + empty folders';
private const AUDIO_EXT = ['mp3', 'm4a', 'aac', 'wav', 'flac', 'ogg', 'opus', 'wma'];
/** Media roots (relative to storage/app) that hold ONLY user media — safe to clean. */
private const SCAN_ROOTS = ['users', 'nas_cache/videos', 'public/videos', 'public/thumbnails', 'public/avatars'];
private bool $dry = true;
private string $appRoot;
public function handle(): int
{
$this->dry = ! $this->option('force');
$this->appRoot = storage_path('app');
$this->info($this->dry ? '=== DRY RUN (no changes) — pass --force to apply ===' : '=== APPLYING CHANGES ===');
$this->newLine();
$plan = $this->buildPlan();
$this->printPlan($plan);
if (! $this->dry) {
$this->applyPlan($plan);
}
$finalPaths = $this->finalReferencedPaths($plan);
$this->handleOrphans($plan, $finalPaths);
$this->handleEmptyDirs();
$this->newLine();
$this->info($this->dry ? 'Dry run complete. Re-run with --force to apply.' : 'Done.');
return self::SUCCESS;
}
// ── helpers ────────────────────────────────────────────────────────────────
private function isAudio(Video $v): bool
{
if ($v->mime_type && str_starts_with($v->mime_type, 'audio/')) return true;
return in_array(strtolower(pathinfo($v->filename ?? '', PATHINFO_EXTENSION)), self::AUDIO_EXT, true);
}
private function titleSlug(string $title): string
{
$title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
$slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
$slug = trim(mb_strtolower($slug), '-');
if (mb_strlen($slug) > 100) $slug = rtrim(mb_substr($slug, 0, 100), '-');
return $slug ?: 'video';
}
private function songDir(Video $v): string
{
$path = (string) $v->path;
if (str_starts_with($path, 'users/')) {
$dir = dirname($path);
if (basename($dir) === 'tracks') $dir = dirname($dir); // promoted-primary case
return $dir;
}
$userSlug = $v->user?->username ?: (string) $v->user_id;
return 'users/' . $userSlug . '/videos/' . $this->titleSlug($v->title);
}
private function trackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
/** Locate the actual local copy of a file given its DB path + filename. */
private function locate(string $dbPath, string $filename): ?string
{
foreach ([$dbPath, 'nas_cache/videos/' . $filename] as $cand) {
if ($cand && is_file($this->appRoot . '/' . $cand)) return $cand;
}
return null;
}
// ── plan ─────────────────────────────────────────────────────────────────
private function buildPlan(): array
{
$plan = [];
foreach (Video::with(['user', 'audioTracks'])->get() as $video) {
if (! $this->isAudio($video)) continue;
$dir = $this->songDir($video);
$base = basename($dir);
// ── primary ──
$pExt = strtolower(pathinfo($video->filename ?: $video->path, PATHINFO_EXTENSION)) ?: 'mp3';
$inDir = str_starts_with((string) $video->path, 'users/') && basename(dirname((string) $video->path)) !== 'tracks';
$pDst = $inDir
? (string) $video->path // already in song folder — keep its name
: $dir . '/' . $this->titleSlug($video->title) . '.' . $pExt; // promoted/legacy — canonical name
$plan[] = $this->item('primary', $video, (string) $video->path, $video->filename, $pDst);
// ── secondaries ──
foreach ($video->audioTracks as $t) {
$tExt = strtolower(pathinfo($t->filename ?: $t->path, PATHINFO_EXTENSION)) ?: 'mp3';
$tDst = $dir . '/' . $this->trackName($base, $t->language, $t->id, $tExt);
$plan[] = $this->item('track', $t, (string) $t->path, $t->filename, $tDst);
}
}
return $plan;
}
private function item(string $type, $model, string $dbPath, ?string $filename, string $dst): array
{
$src = $this->locate($dbPath, (string) $filename);
return [
'type' => $type,
'id' => $model->id,
'model' => $model,
'db_path' => $dbPath,
'src' => $src, // actual local file (or null if only on NAS)
'dst' => $dst,
'needsMove' => $src !== null && $src !== $dst,
'atDest' => $src !== null && $src === $dst,
'dbStale' => $dbPath !== $dst, // DB needs updating even if file already at dest
];
}
private function printPlan(array $plan): void
{
$moves = array_filter($plan, fn ($p) => $p['needsMove']);
$miss = array_filter($plan, fn ($p) => $p['src'] === null);
$this->line('<comment>Consolidation / rename plan:</comment>');
foreach ($plan as $p) {
if ($p['src'] === null) {
$this->line(sprintf(' <fg=red>[%s #%d] NO LOCAL COPY</> (db: %s) — skipped', $p['type'], $p['id'], $p['db_path']));
continue;
}
if ($p['needsMove']) {
$this->line(sprintf(' [%s #%d] %s', $p['type'], $p['id'], $p['src']));
$this->line(sprintf(' -> %s', $p['dst']));
}
}
$this->newLine();
$this->line(' moves: ' . count($moves) . ' | already correct: ' . (count($plan) - count($moves) - count($miss)) . ' | no local copy: ' . count($miss));
$this->newLine();
}
private function applyPlan(array $plan): void
{
// Phase 1: move every file that needs moving to a unique temp name (collision-safe).
$temps = [];
foreach ($plan as $i => $p) {
if (! $p['needsMove']) continue;
$srcAbs = $this->appRoot . '/' . $p['src'];
$tmpRel = dirname($p['dst']) . '/.reorg_' . $p['type'] . '_' . $p['id'] . '.tmp';
$tmpAbs = $this->appRoot . '/' . $tmpRel;
@mkdir(dirname($tmpAbs), 0755, true);
if (@rename($srcAbs, $tmpAbs)) {
$temps[$i] = $tmpRel;
} else {
$this->error(" FAILED phase1 move: {$p['src']}");
}
}
// Phase 2: temp -> final, then update DB.
foreach ($plan as $i => $p) {
if (isset($temps[$i])) {
$tmpAbs = $this->appRoot . '/' . $temps[$i];
$dstAbs = $this->appRoot . '/' . $p['dst'];
if (! @rename($tmpAbs, $dstAbs)) {
$this->error(" FAILED phase2 move -> {$p['dst']}");
continue;
}
$this->line(" moved #{$p['id']} -> {$p['dst']}");
}
// Update DB record whenever the stored path/name is out of date.
if ($p['src'] !== null && $p['dbStale']) {
$p['model']->update(['path' => $p['dst'], 'filename' => basename($p['dst'])]);
}
}
}
// ── orphans + empties ──────────────────────────────────────────────────────
/** All file paths (relative to storage/app) referenced by the DB after migration. */
private function finalReferencedPaths(array $plan): array
{
$paths = [];
$planned = []; // db_path keyed -> handled by plan
foreach ($plan as $p) {
if ($p['src'] !== null) { $paths[$p['dst']] = true; $planned[$p['db_path']] = true; }
else { $paths[$p['db_path']] = true; } // keep NAS-only refs
}
foreach (Video::all() as $v) {
if ($v->path && ! isset($planned[$v->path]) && ! $this->plannedPrimary($plan, $v->id)) $paths[$v->path] = true;
if ($v->thumbnail) $paths[$v->thumbnail] = true;
if ($v->slideshow_video_path) $paths[$v->slideshow_video_path] = true; // generated download video
}
foreach (VideoAudioTrack::all() as $t) {
if ($t->path && ! isset($planned[$t->path]) && ! $this->plannedTrack($plan, $t->id)) $paths[$t->path] = true;
}
foreach (DB::table('video_slides')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (User::all() as $u) { if ($u->avatar) $paths[$u->avatar] = true; if ($u->banner) $paths[$u->banner] = true; }
foreach (Playlist::all() as $pl) if ($pl->thumbnail) $paths[$pl->thumbnail] = true;
foreach (DB::table('post_images')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (DB::table('posts')->pluck('image') as $f) if ($f) $paths[$f] = true;
return $paths;
}
private function plannedPrimary(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'primary' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function plannedTrack(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'track' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function handleOrphans(array $plan, array $finalPaths): void
{
// Conservative: also protect any file whose basename matches a referenced basename
// or a planned move-source (sources are being moved, not orphaned).
$keepBasenames = ['meta.json' => true];
foreach (array_keys($finalPaths) as $rel) $keepBasenames[basename($rel)] = true;
foreach ($plan as $p) if ($p['src']) $keepBasenames[basename($p['src'])] = true;
// Files actively moved this run (their old location is vacated, not an orphan to re-check).
$movedFrom = [];
foreach ($plan as $p) if ($p['needsMove']) $movedFrom[$p['src']] = true;
$orphans = [];
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if (! $file->isFile()) continue;
$rel = substr($file->getPathname(), strlen($this->appRoot) + 1);
if (isset($finalPaths[$rel])) continue;
if (isset($keepBasenames[$file->getFilename()])) continue;
if (isset($movedFrom[$rel])) continue;
// Everything under a song's cache/ is a regenerable render — never an orphan.
if (str_contains($rel, '/cache/')) continue;
$orphans[] = $rel;
}
}
$this->newLine();
if (! $orphans) { $this->line('No orphan files found.'); return; }
$this->line('<comment>Orphan files (no DB reference) — ' . count($orphans) . ':</comment>');
$bytes = 0;
foreach ($orphans as $rel) {
$abs = $this->appRoot . '/' . $rel;
$sz = is_file($abs) ? filesize($abs) : 0; $bytes += $sz;
$this->line(sprintf(' %s (%s KB)', $rel, number_format($sz / 1024, 1)));
if (! $this->dry) @unlink($abs);
}
$this->line(' total: ' . number_format($bytes / 1048576, 1) . ' MB' . ($this->dry ? '' : ' (deleted)'));
}
private function handleEmptyDirs(): void
{
$this->newLine();
$removed = 0; $listed = [];
// Loop because removing leaf dirs can empty their parents.
do {
$found = 0;
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
if (! $f->isDir()) continue;
if ((new \FilesystemIterator($f->getPathname()))->valid()) continue; // not empty
$rel = substr($f->getPathname(), strlen($this->appRoot) + 1);
if (isset($listed[$rel])) continue;
$listed[$rel] = true; $found++;
if (! $this->dry) { @rmdir($f->getPathname()); $removed++; }
}
}
} while (! $this->dry && $found > 0);
if (! $listed) { $this->line('No empty folders found.'); return; }
$this->line('<comment>Empty folders — ' . count($listed) . ':</comment>');
foreach (array_keys($listed) as $rel) $this->line(' ' . $rel);
if (! $this->dry) $this->line(" ({$removed} removed)");
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Notifications\WeeklyDigestNotification;
use Illuminate\Console\Command;
class SendWeeklyDigest extends Command
{
protected $signature = 'digest:weekly';
protected $description = 'Send weekly activity digest emails to creators who have it enabled';
public function handle(): void
{
$users = User::whereHas('videos')->get();
$sent = 0;
foreach ($users as $user) {
if (! $user->notificationPref('email_weekly_digest')) {
continue;
}
try {
$user->notify(new WeeklyDigestNotification($user));
$sent++;
} catch (\Throwable $e) {
\Log::error('Weekly digest failed for user ' . $user->id . ': ' . $e->getMessage());
}
}
$this->info("Sent weekly digest to {$sent} users.");
}
}

View File

@ -12,42 +12,7 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
$interval = $this->getCleanupInterval(); // $schedule->command('inspire')->hourly();
$schedule->command('cleanup:orphaned-videos --force')
->cron("*/{$interval} * * * *")
->withoutOverlapping()
->runInBackground();
// Evict NAS stream-cache files older than 24 hours
$schedule->call(function () {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->clearNasCache(24);
}
})->daily()->name('nas-cache-evict');
// Clean up stale temp files in public/tmp older than 1 hour
$schedule->call(function () {
$tmpDir = storage_path('app/public/tmp');
if (! is_dir($tmpDir)) return;
$cutoff = time() - 3600;
foreach (glob("{$tmpDir}/*") as $file) {
if (is_file($file) && filemtime($file) < $cutoff) @unlink($file);
}
})->hourly()->name('tmp-cleanup');
// Auto-sync local files to NAS when NAS comes back online.
// Runs every 10 minutes; exits immediately when NAS is unreachable.
$schedule->command('nas:auto-sync')
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
// Weekly activity digest — every Monday at 9:00 AM (Bahrain time)
$schedule->command('digest:weekly')
->weeklyOn(1, '09:00')
->withoutOverlapping()
->runInBackground();
} }
/** /**
@ -59,12 +24,4 @@ class Kernel extends ConsoleKernel
require base_path('routes/console.php'); require base_path('routes/console.php');
} }
/**
* Get the interval in minutes for cleanup (from .env)
*/
protected function getCleanupInterval(): int
{
return (int) env('CLEANUP_INTERVAL_MINUTES', 30);
}
} }

View File

@ -1,359 +0,0 @@
<?php
namespace App\Data;
class Countries
{
/**
* All countries keyed by ISO2 code.
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
*/
public static function all(): array
{
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
$f = fn(string $c): string => strtolower($c);
return [
// ── AFRICA ──────────────────────────────────────────────────────────
'DZ' => ['name'=>'Algeria', 'iso2'=>'DZ','iso3'=>'DZA','flag'=>$f('DZ'),'dial_code'=>'+213', 'timezone'=>'Africa/Algiers', 'currency'=>'DZD'],
'AO' => ['name'=>'Angola', 'iso2'=>'AO','iso3'=>'AGO','flag'=>$f('AO'),'dial_code'=>'+244', 'timezone'=>'Africa/Luanda', 'currency'=>'AOA'],
'BJ' => ['name'=>'Benin', 'iso2'=>'BJ','iso3'=>'BEN','flag'=>$f('BJ'),'dial_code'=>'+229', 'timezone'=>'Africa/Porto-Novo', 'currency'=>'XOF'],
'BW' => ['name'=>'Botswana', 'iso2'=>'BW','iso3'=>'BWA','flag'=>$f('BW'),'dial_code'=>'+267', 'timezone'=>'Africa/Gaborone', 'currency'=>'BWP'],
'BF' => ['name'=>'Burkina Faso', 'iso2'=>'BF','iso3'=>'BFA','flag'=>$f('BF'),'dial_code'=>'+226', 'timezone'=>'Africa/Ouagadougou', 'currency'=>'XOF'],
'BI' => ['name'=>'Burundi', 'iso2'=>'BI','iso3'=>'BDI','flag'=>$f('BI'),'dial_code'=>'+257', 'timezone'=>'Africa/Bujumbura', 'currency'=>'BIF'],
'CV' => ['name'=>'Cabo Verde', 'iso2'=>'CV','iso3'=>'CPV','flag'=>$f('CV'),'dial_code'=>'+238', 'timezone'=>'Atlantic/Cape_Verde', 'currency'=>'CVE'],
'CM' => ['name'=>'Cameroon', 'iso2'=>'CM','iso3'=>'CMR','flag'=>$f('CM'),'dial_code'=>'+237', 'timezone'=>'Africa/Douala', 'currency'=>'XAF'],
'CF' => ['name'=>'Central African Republic', 'iso2'=>'CF','iso3'=>'CAF','flag'=>$f('CF'),'dial_code'=>'+236', 'timezone'=>'Africa/Bangui', 'currency'=>'XAF'],
'TD' => ['name'=>'Chad', 'iso2'=>'TD','iso3'=>'TCD','flag'=>$f('TD'),'dial_code'=>'+235', 'timezone'=>'Africa/Ndjamena', 'currency'=>'XAF'],
'KM' => ['name'=>'Comoros', 'iso2'=>'KM','iso3'=>'COM','flag'=>$f('KM'),'dial_code'=>'+269', 'timezone'=>'Indian/Comoro', 'currency'=>'KMF'],
'CD' => ['name'=>'Congo (DRC)', 'iso2'=>'CD','iso3'=>'COD','flag'=>$f('CD'),'dial_code'=>'+243', 'timezone'=>'Africa/Kinshasa', 'currency'=>'CDF'],
'CG' => ['name'=>'Congo (Republic)', 'iso2'=>'CG','iso3'=>'COG','flag'=>$f('CG'),'dial_code'=>'+242', 'timezone'=>'Africa/Brazzaville', 'currency'=>'XAF'],
'DJ' => ['name'=>'Djibouti', 'iso2'=>'DJ','iso3'=>'DJI','flag'=>$f('DJ'),'dial_code'=>'+253', 'timezone'=>'Africa/Djibouti', 'currency'=>'DJF'],
'EG' => ['name'=>'Egypt', 'iso2'=>'EG','iso3'=>'EGY','flag'=>$f('EG'),'dial_code'=>'+20', 'timezone'=>'Africa/Cairo', 'currency'=>'EGP'],
'GQ' => ['name'=>'Equatorial Guinea', 'iso2'=>'GQ','iso3'=>'GNQ','flag'=>$f('GQ'),'dial_code'=>'+240', 'timezone'=>'Africa/Malabo', 'currency'=>'XAF'],
'ER' => ['name'=>'Eritrea', 'iso2'=>'ER','iso3'=>'ERI','flag'=>$f('ER'),'dial_code'=>'+291', 'timezone'=>'Africa/Asmara', 'currency'=>'ERN'],
'SZ' => ['name'=>'Eswatini', 'iso2'=>'SZ','iso3'=>'SWZ','flag'=>$f('SZ'),'dial_code'=>'+268', 'timezone'=>'Africa/Mbabane', 'currency'=>'SZL'],
'ET' => ['name'=>'Ethiopia', 'iso2'=>'ET','iso3'=>'ETH','flag'=>$f('ET'),'dial_code'=>'+251', 'timezone'=>'Africa/Addis_Ababa', 'currency'=>'ETB'],
'GA' => ['name'=>'Gabon', 'iso2'=>'GA','iso3'=>'GAB','flag'=>$f('GA'),'dial_code'=>'+241', 'timezone'=>'Africa/Libreville', 'currency'=>'XAF'],
'GM' => ['name'=>'Gambia', 'iso2'=>'GM','iso3'=>'GMB','flag'=>$f('GM'),'dial_code'=>'+220', 'timezone'=>'Africa/Banjul', 'currency'=>'GMD'],
'GH' => ['name'=>'Ghana', 'iso2'=>'GH','iso3'=>'GHA','flag'=>$f('GH'),'dial_code'=>'+233', 'timezone'=>'Africa/Accra', 'currency'=>'GHS'],
'GN' => ['name'=>'Guinea', 'iso2'=>'GN','iso3'=>'GIN','flag'=>$f('GN'),'dial_code'=>'+224', 'timezone'=>'Africa/Conakry', 'currency'=>'GNF'],
'GW' => ['name'=>'Guinea-Bissau', 'iso2'=>'GW','iso3'=>'GNB','flag'=>$f('GW'),'dial_code'=>'+245', 'timezone'=>'Africa/Bissau', 'currency'=>'XOF'],
'CI' => ['name'=>'Ivory Coast', 'iso2'=>'CI','iso3'=>'CIV','flag'=>$f('CI'),'dial_code'=>'+225', 'timezone'=>'Africa/Abidjan', 'currency'=>'XOF'],
'KE' => ['name'=>'Kenya', 'iso2'=>'KE','iso3'=>'KEN','flag'=>$f('KE'),'dial_code'=>'+254', 'timezone'=>'Africa/Nairobi', 'currency'=>'KES'],
'LS' => ['name'=>'Lesotho', 'iso2'=>'LS','iso3'=>'LSO','flag'=>$f('LS'),'dial_code'=>'+266', 'timezone'=>'Africa/Maseru', 'currency'=>'LSL'],
'LR' => ['name'=>'Liberia', 'iso2'=>'LR','iso3'=>'LBR','flag'=>$f('LR'),'dial_code'=>'+231', 'timezone'=>'Africa/Monrovia', 'currency'=>'LRD'],
'LY' => ['name'=>'Libya', 'iso2'=>'LY','iso3'=>'LBY','flag'=>$f('LY'),'dial_code'=>'+218', 'timezone'=>'Africa/Tripoli', 'currency'=>'LYD'],
'MG' => ['name'=>'Madagascar', 'iso2'=>'MG','iso3'=>'MDG','flag'=>$f('MG'),'dial_code'=>'+261', 'timezone'=>'Indian/Antananarivo', 'currency'=>'MGA'],
'MW' => ['name'=>'Malawi', 'iso2'=>'MW','iso3'=>'MWI','flag'=>$f('MW'),'dial_code'=>'+265', 'timezone'=>'Africa/Blantyre', 'currency'=>'MWK'],
'ML' => ['name'=>'Mali', 'iso2'=>'ML','iso3'=>'MLI','flag'=>$f('ML'),'dial_code'=>'+223', 'timezone'=>'Africa/Bamako', 'currency'=>'XOF'],
'MR' => ['name'=>'Mauritania', 'iso2'=>'MR','iso3'=>'MRT','flag'=>$f('MR'),'dial_code'=>'+222', 'timezone'=>'Africa/Nouakchott', 'currency'=>'MRU'],
'MU' => ['name'=>'Mauritius', 'iso2'=>'MU','iso3'=>'MUS','flag'=>$f('MU'),'dial_code'=>'+230', 'timezone'=>'Indian/Mauritius', 'currency'=>'MUR'],
'MA' => ['name'=>'Morocco', 'iso2'=>'MA','iso3'=>'MAR','flag'=>$f('MA'),'dial_code'=>'+212', 'timezone'=>'Africa/Casablanca', 'currency'=>'MAD'],
'MZ' => ['name'=>'Mozambique', 'iso2'=>'MZ','iso3'=>'MOZ','flag'=>$f('MZ'),'dial_code'=>'+258', 'timezone'=>'Africa/Maputo', 'currency'=>'MZN'],
'NA' => ['name'=>'Namibia', 'iso2'=>'NA','iso3'=>'NAM','flag'=>$f('NA'),'dial_code'=>'+264', 'timezone'=>'Africa/Windhoek', 'currency'=>'NAD'],
'NE' => ['name'=>'Niger', 'iso2'=>'NE','iso3'=>'NER','flag'=>$f('NE'),'dial_code'=>'+227', 'timezone'=>'Africa/Niamey', 'currency'=>'XOF'],
'NG' => ['name'=>'Nigeria', 'iso2'=>'NG','iso3'=>'NGA','flag'=>$f('NG'),'dial_code'=>'+234', 'timezone'=>'Africa/Lagos', 'currency'=>'NGN'],
'RW' => ['name'=>'Rwanda', 'iso2'=>'RW','iso3'=>'RWA','flag'=>$f('RW'),'dial_code'=>'+250', 'timezone'=>'Africa/Kigali', 'currency'=>'RWF'],
'ST' => ['name'=>'São Tomé & Príncipe', 'iso2'=>'ST','iso3'=>'STP','flag'=>$f('ST'),'dial_code'=>'+239', 'timezone'=>'Africa/Sao_Tome', 'currency'=>'STN'],
'SN' => ['name'=>'Senegal', 'iso2'=>'SN','iso3'=>'SEN','flag'=>$f('SN'),'dial_code'=>'+221', 'timezone'=>'Africa/Dakar', 'currency'=>'XOF'],
'SC' => ['name'=>'Seychelles', 'iso2'=>'SC','iso3'=>'SYC','flag'=>$f('SC'),'dial_code'=>'+248', 'timezone'=>'Indian/Mahe', 'currency'=>'SCR'],
'SL' => ['name'=>'Sierra Leone', 'iso2'=>'SL','iso3'=>'SLE','flag'=>$f('SL'),'dial_code'=>'+232', 'timezone'=>'Africa/Freetown', 'currency'=>'SLL'],
'SO' => ['name'=>'Somalia', 'iso2'=>'SO','iso3'=>'SOM','flag'=>$f('SO'),'dial_code'=>'+252', 'timezone'=>'Africa/Mogadishu', 'currency'=>'SOS'],
'ZA' => ['name'=>'South Africa', 'iso2'=>'ZA','iso3'=>'ZAF','flag'=>$f('ZA'),'dial_code'=>'+27', 'timezone'=>'Africa/Johannesburg', 'currency'=>'ZAR'],
'SS' => ['name'=>'South Sudan', 'iso2'=>'SS','iso3'=>'SSD','flag'=>$f('SS'),'dial_code'=>'+211', 'timezone'=>'Africa/Juba', 'currency'=>'SSP'],
'SD' => ['name'=>'Sudan', 'iso2'=>'SD','iso3'=>'SDN','flag'=>$f('SD'),'dial_code'=>'+249', 'timezone'=>'Africa/Khartoum', 'currency'=>'SDG'],
'TZ' => ['name'=>'Tanzania', 'iso2'=>'TZ','iso3'=>'TZA','flag'=>$f('TZ'),'dial_code'=>'+255', 'timezone'=>'Africa/Dar_es_Salaam', 'currency'=>'TZS'],
'TG' => ['name'=>'Togo', 'iso2'=>'TG','iso3'=>'TGO','flag'=>$f('TG'),'dial_code'=>'+228', 'timezone'=>'Africa/Lome', 'currency'=>'XOF'],
'TN' => ['name'=>'Tunisia', 'iso2'=>'TN','iso3'=>'TUN','flag'=>$f('TN'),'dial_code'=>'+216', 'timezone'=>'Africa/Tunis', 'currency'=>'TND'],
'UG' => ['name'=>'Uganda', 'iso2'=>'UG','iso3'=>'UGA','flag'=>$f('UG'),'dial_code'=>'+256', 'timezone'=>'Africa/Kampala', 'currency'=>'UGX'],
'ZM' => ['name'=>'Zambia', 'iso2'=>'ZM','iso3'=>'ZMB','flag'=>$f('ZM'),'dial_code'=>'+260', 'timezone'=>'Africa/Lusaka', 'currency'=>'ZMW'],
'ZW' => ['name'=>'Zimbabwe', 'iso2'=>'ZW','iso3'=>'ZWE','flag'=>$f('ZW'),'dial_code'=>'+263', 'timezone'=>'Africa/Harare', 'currency'=>'ZWL'],
// ── AMERICAS ─────────────────────────────────────────────────────────
'AG' => ['name'=>'Antigua & Barbuda', 'iso2'=>'AG','iso3'=>'ATG','flag'=>$f('AG'),'dial_code'=>'+1', 'timezone'=>'America/Antigua', 'currency'=>'XCD'],
'AR' => ['name'=>'Argentina', 'iso2'=>'AR','iso3'=>'ARG','flag'=>$f('AR'),'dial_code'=>'+54', 'timezone'=>'America/Argentina/Buenos_Aires','currency'=>'ARS'],
'BS' => ['name'=>'Bahamas', 'iso2'=>'BS','iso3'=>'BHS','flag'=>$f('BS'),'dial_code'=>'+1', 'timezone'=>'America/Nassau', 'currency'=>'BSD'],
'BB' => ['name'=>'Barbados', 'iso2'=>'BB','iso3'=>'BRB','flag'=>$f('BB'),'dial_code'=>'+1', 'timezone'=>'America/Barbados', 'currency'=>'BBD'],
'BZ' => ['name'=>'Belize', 'iso2'=>'BZ','iso3'=>'BLZ','flag'=>$f('BZ'),'dial_code'=>'+501', 'timezone'=>'America/Belize', 'currency'=>'BZD'],
'BO' => ['name'=>'Bolivia', 'iso2'=>'BO','iso3'=>'BOL','flag'=>$f('BO'),'dial_code'=>'+591', 'timezone'=>'America/La_Paz', 'currency'=>'BOB'],
'BR' => ['name'=>'Brazil', 'iso2'=>'BR','iso3'=>'BRA','flag'=>$f('BR'),'dial_code'=>'+55', 'timezone'=>'America/Sao_Paulo', 'currency'=>'BRL'],
'CA' => ['name'=>'Canada', 'iso2'=>'CA','iso3'=>'CAN','flag'=>$f('CA'),'dial_code'=>'+1', 'timezone'=>'America/Toronto', 'currency'=>'CAD'],
'CL' => ['name'=>'Chile', 'iso2'=>'CL','iso3'=>'CHL','flag'=>$f('CL'),'dial_code'=>'+56', 'timezone'=>'America/Santiago', 'currency'=>'CLP'],
'CO' => ['name'=>'Colombia', 'iso2'=>'CO','iso3'=>'COL','flag'=>$f('CO'),'dial_code'=>'+57', 'timezone'=>'America/Bogota', 'currency'=>'COP'],
'CR' => ['name'=>'Costa Rica', 'iso2'=>'CR','iso3'=>'CRI','flag'=>$f('CR'),'dial_code'=>'+506', 'timezone'=>'America/Costa_Rica', 'currency'=>'CRC'],
'CU' => ['name'=>'Cuba', 'iso2'=>'CU','iso3'=>'CUB','flag'=>$f('CU'),'dial_code'=>'+53', 'timezone'=>'America/Havana', 'currency'=>'CUP'],
'DM' => ['name'=>'Dominica', 'iso2'=>'DM','iso3'=>'DMA','flag'=>$f('DM'),'dial_code'=>'+1', 'timezone'=>'America/Dominica', 'currency'=>'XCD'],
'DO' => ['name'=>'Dominican Republic', 'iso2'=>'DO','iso3'=>'DOM','flag'=>$f('DO'),'dial_code'=>'+1', 'timezone'=>'America/Santo_Domingo', 'currency'=>'DOP'],
'EC' => ['name'=>'Ecuador', 'iso2'=>'EC','iso3'=>'ECU','flag'=>$f('EC'),'dial_code'=>'+593', 'timezone'=>'America/Guayaquil', 'currency'=>'USD'],
'SV' => ['name'=>'El Salvador', 'iso2'=>'SV','iso3'=>'SLV','flag'=>$f('SV'),'dial_code'=>'+503', 'timezone'=>'America/El_Salvador', 'currency'=>'USD'],
'GD' => ['name'=>'Grenada', 'iso2'=>'GD','iso3'=>'GRD','flag'=>$f('GD'),'dial_code'=>'+1', 'timezone'=>'America/Grenada', 'currency'=>'XCD'],
'GT' => ['name'=>'Guatemala', 'iso2'=>'GT','iso3'=>'GTM','flag'=>$f('GT'),'dial_code'=>'+502', 'timezone'=>'America/Guatemala', 'currency'=>'GTQ'],
'GY' => ['name'=>'Guyana', 'iso2'=>'GY','iso3'=>'GUY','flag'=>$f('GY'),'dial_code'=>'+592', 'timezone'=>'America/Guyana', 'currency'=>'GYD'],
'HT' => ['name'=>'Haiti', 'iso2'=>'HT','iso3'=>'HTI','flag'=>$f('HT'),'dial_code'=>'+509', 'timezone'=>'America/Port-au-Prince', 'currency'=>'HTG'],
'HN' => ['name'=>'Honduras', 'iso2'=>'HN','iso3'=>'HND','flag'=>$f('HN'),'dial_code'=>'+504', 'timezone'=>'America/Tegucigalpa', 'currency'=>'HNL'],
'JM' => ['name'=>'Jamaica', 'iso2'=>'JM','iso3'=>'JAM','flag'=>$f('JM'),'dial_code'=>'+1', 'timezone'=>'America/Jamaica', 'currency'=>'JMD'],
'MX' => ['name'=>'Mexico', 'iso2'=>'MX','iso3'=>'MEX','flag'=>$f('MX'),'dial_code'=>'+52', 'timezone'=>'America/Mexico_City', 'currency'=>'MXN'],
'NI' => ['name'=>'Nicaragua', 'iso2'=>'NI','iso3'=>'NIC','flag'=>$f('NI'),'dial_code'=>'+505', 'timezone'=>'America/Managua', 'currency'=>'NIO'],
'PA' => ['name'=>'Panama', 'iso2'=>'PA','iso3'=>'PAN','flag'=>$f('PA'),'dial_code'=>'+507', 'timezone'=>'America/Panama', 'currency'=>'PAB'],
'PY' => ['name'=>'Paraguay', 'iso2'=>'PY','iso3'=>'PRY','flag'=>$f('PY'),'dial_code'=>'+595', 'timezone'=>'America/Asuncion', 'currency'=>'PYG'],
'PE' => ['name'=>'Peru', 'iso2'=>'PE','iso3'=>'PER','flag'=>$f('PE'),'dial_code'=>'+51', 'timezone'=>'America/Lima', 'currency'=>'PEN'],
'KN' => ['name'=>'Saint Kitts & Nevis', 'iso2'=>'KN','iso3'=>'KNA','flag'=>$f('KN'),'dial_code'=>'+1', 'timezone'=>'America/St_Kitts', 'currency'=>'XCD'],
'LC' => ['name'=>'Saint Lucia', 'iso2'=>'LC','iso3'=>'LCA','flag'=>$f('LC'),'dial_code'=>'+1', 'timezone'=>'America/St_Lucia', 'currency'=>'XCD'],
'VC' => ['name'=>'Saint Vincent & Grenadines','iso2'=>'VC','iso3'=>'VCT','flag'=>$f('VC'),'dial_code'=>'+1', 'timezone'=>'America/St_Vincent', 'currency'=>'XCD'],
'SR' => ['name'=>'Suriname', 'iso2'=>'SR','iso3'=>'SUR','flag'=>$f('SR'),'dial_code'=>'+597', 'timezone'=>'America/Paramaribo', 'currency'=>'SRD'],
'TT' => ['name'=>'Trinidad & Tobago', 'iso2'=>'TT','iso3'=>'TTO','flag'=>$f('TT'),'dial_code'=>'+1', 'timezone'=>'America/Port_of_Spain', 'currency'=>'TTD'],
'US' => ['name'=>'United States', 'iso2'=>'US','iso3'=>'USA','flag'=>$f('US'),'dial_code'=>'+1', 'timezone'=>'America/New_York', 'currency'=>'USD'],
'UY' => ['name'=>'Uruguay', 'iso2'=>'UY','iso3'=>'URY','flag'=>$f('UY'),'dial_code'=>'+598', 'timezone'=>'America/Montevideo', 'currency'=>'UYU'],
'VE' => ['name'=>'Venezuela', 'iso2'=>'VE','iso3'=>'VEN','flag'=>$f('VE'),'dial_code'=>'+58', 'timezone'=>'America/Caracas', 'currency'=>'VES'],
// ── ASIA & MIDDLE EAST ────────────────────────────────────────────────
'AF' => ['name'=>'Afghanistan', 'iso2'=>'AF','iso3'=>'AFG','flag'=>$f('AF'),'dial_code'=>'+93', 'timezone'=>'Asia/Kabul', 'currency'=>'AFN'],
'AM' => ['name'=>'Armenia', 'iso2'=>'AM','iso3'=>'ARM','flag'=>$f('AM'),'dial_code'=>'+374', 'timezone'=>'Asia/Yerevan', 'currency'=>'AMD'],
'AZ' => ['name'=>'Azerbaijan', 'iso2'=>'AZ','iso3'=>'AZE','flag'=>$f('AZ'),'dial_code'=>'+994', 'timezone'=>'Asia/Baku', 'currency'=>'AZN'],
'BH' => ['name'=>'Bahrain', 'iso2'=>'BH','iso3'=>'BHR','flag'=>$f('BH'),'dial_code'=>'+973', 'timezone'=>'Asia/Bahrain', 'currency'=>'BHD'],
'BD' => ['name'=>'Bangladesh', 'iso2'=>'BD','iso3'=>'BGD','flag'=>$f('BD'),'dial_code'=>'+880', 'timezone'=>'Asia/Dhaka', 'currency'=>'BDT'],
'BT' => ['name'=>'Bhutan', 'iso2'=>'BT','iso3'=>'BTN','flag'=>$f('BT'),'dial_code'=>'+975', 'timezone'=>'Asia/Thimphu', 'currency'=>'BTN'],
'BN' => ['name'=>'Brunei', 'iso2'=>'BN','iso3'=>'BRN','flag'=>$f('BN'),'dial_code'=>'+673', 'timezone'=>'Asia/Brunei', 'currency'=>'BND'],
'KH' => ['name'=>'Cambodia', 'iso2'=>'KH','iso3'=>'KHM','flag'=>$f('KH'),'dial_code'=>'+855', 'timezone'=>'Asia/Phnom_Penh', 'currency'=>'KHR'],
'CN' => ['name'=>'China', 'iso2'=>'CN','iso3'=>'CHN','flag'=>$f('CN'),'dial_code'=>'+86', 'timezone'=>'Asia/Shanghai', 'currency'=>'CNY'],
'CY' => ['name'=>'Cyprus', 'iso2'=>'CY','iso3'=>'CYP','flag'=>$f('CY'),'dial_code'=>'+357', 'timezone'=>'Asia/Nicosia', 'currency'=>'EUR'],
'GE' => ['name'=>'Georgia', 'iso2'=>'GE','iso3'=>'GEO','flag'=>$f('GE'),'dial_code'=>'+995', 'timezone'=>'Asia/Tbilisi', 'currency'=>'GEL'],
'HK' => ['name'=>'Hong Kong', 'iso2'=>'HK','iso3'=>'HKG','flag'=>$f('HK'),'dial_code'=>'+852', 'timezone'=>'Asia/Hong_Kong', 'currency'=>'HKD'],
'IN' => ['name'=>'India', 'iso2'=>'IN','iso3'=>'IND','flag'=>$f('IN'),'dial_code'=>'+91', 'timezone'=>'Asia/Kolkata', 'currency'=>'INR'],
'ID' => ['name'=>'Indonesia', 'iso2'=>'ID','iso3'=>'IDN','flag'=>$f('ID'),'dial_code'=>'+62', 'timezone'=>'Asia/Jakarta', 'currency'=>'IDR'],
'IR' => ['name'=>'Iran', 'iso2'=>'IR','iso3'=>'IRN','flag'=>$f('IR'),'dial_code'=>'+98', 'timezone'=>'Asia/Tehran', 'currency'=>'IRR'],
'IQ' => ['name'=>'Iraq', 'iso2'=>'IQ','iso3'=>'IRQ','flag'=>$f('IQ'),'dial_code'=>'+964', 'timezone'=>'Asia/Baghdad', 'currency'=>'IQD'],
'IL' => ['name'=>'Israel', 'iso2'=>'IL','iso3'=>'ISR','flag'=>$f('IL'),'dial_code'=>'+972', 'timezone'=>'Asia/Jerusalem', 'currency'=>'ILS'],
'JP' => ['name'=>'Japan', 'iso2'=>'JP','iso3'=>'JPN','flag'=>$f('JP'),'dial_code'=>'+81', 'timezone'=>'Asia/Tokyo', 'currency'=>'JPY'],
'JO' => ['name'=>'Jordan', 'iso2'=>'JO','iso3'=>'JOR','flag'=>$f('JO'),'dial_code'=>'+962', 'timezone'=>'Asia/Amman', 'currency'=>'JOD'],
'KZ' => ['name'=>'Kazakhstan', 'iso2'=>'KZ','iso3'=>'KAZ','flag'=>$f('KZ'),'dial_code'=>'+7', 'timezone'=>'Asia/Almaty', 'currency'=>'KZT'],
'KW' => ['name'=>'Kuwait', 'iso2'=>'KW','iso3'=>'KWT','flag'=>$f('KW'),'dial_code'=>'+965', 'timezone'=>'Asia/Kuwait', 'currency'=>'KWD'],
'KG' => ['name'=>'Kyrgyzstan', 'iso2'=>'KG','iso3'=>'KGZ','flag'=>$f('KG'),'dial_code'=>'+996', 'timezone'=>'Asia/Bishkek', 'currency'=>'KGS'],
'LA' => ['name'=>'Laos', 'iso2'=>'LA','iso3'=>'LAO','flag'=>$f('LA'),'dial_code'=>'+856', 'timezone'=>'Asia/Vientiane', 'currency'=>'LAK'],
'LB' => ['name'=>'Lebanon', 'iso2'=>'LB','iso3'=>'LBN','flag'=>$f('LB'),'dial_code'=>'+961', 'timezone'=>'Asia/Beirut', 'currency'=>'LBP'],
'MO' => ['name'=>'Macau', 'iso2'=>'MO','iso3'=>'MAC','flag'=>$f('MO'),'dial_code'=>'+853', 'timezone'=>'Asia/Macau', 'currency'=>'MOP'],
'MY' => ['name'=>'Malaysia', 'iso2'=>'MY','iso3'=>'MYS','flag'=>$f('MY'),'dial_code'=>'+60', 'timezone'=>'Asia/Kuala_Lumpur', 'currency'=>'MYR'],
'MV' => ['name'=>'Maldives', 'iso2'=>'MV','iso3'=>'MDV','flag'=>$f('MV'),'dial_code'=>'+960', 'timezone'=>'Indian/Maldives', 'currency'=>'MVR'],
'MN' => ['name'=>'Mongolia', 'iso2'=>'MN','iso3'=>'MNG','flag'=>$f('MN'),'dial_code'=>'+976', 'timezone'=>'Asia/Ulaanbaatar', 'currency'=>'MNT'],
'MM' => ['name'=>'Myanmar', 'iso2'=>'MM','iso3'=>'MMR','flag'=>$f('MM'),'dial_code'=>'+95', 'timezone'=>'Asia/Yangon', 'currency'=>'MMK'],
'NP' => ['name'=>'Nepal', 'iso2'=>'NP','iso3'=>'NPL','flag'=>$f('NP'),'dial_code'=>'+977', 'timezone'=>'Asia/Kathmandu', 'currency'=>'NPR'],
'KP' => ['name'=>'North Korea', 'iso2'=>'KP','iso3'=>'PRK','flag'=>$f('KP'),'dial_code'=>'+850', 'timezone'=>'Asia/Pyongyang', 'currency'=>'KPW'],
'OM' => ['name'=>'Oman', 'iso2'=>'OM','iso3'=>'OMN','flag'=>$f('OM'),'dial_code'=>'+968', 'timezone'=>'Asia/Muscat', 'currency'=>'OMR'],
'PK' => ['name'=>'Pakistan', 'iso2'=>'PK','iso3'=>'PAK','flag'=>$f('PK'),'dial_code'=>'+92', 'timezone'=>'Asia/Karachi', 'currency'=>'PKR'],
'PS' => ['name'=>'Palestine', 'iso2'=>'PS','iso3'=>'PSE','flag'=>$f('PS'),'dial_code'=>'+970', 'timezone'=>'Asia/Hebron', 'currency'=>'ILS'],
'PH' => ['name'=>'Philippines', 'iso2'=>'PH','iso3'=>'PHL','flag'=>$f('PH'),'dial_code'=>'+63', 'timezone'=>'Asia/Manila', 'currency'=>'PHP'],
'QA' => ['name'=>'Qatar', 'iso2'=>'QA','iso3'=>'QAT','flag'=>$f('QA'),'dial_code'=>'+974', 'timezone'=>'Asia/Qatar', 'currency'=>'QAR'],
'SA' => ['name'=>'Saudi Arabia', 'iso2'=>'SA','iso3'=>'SAU','flag'=>$f('SA'),'dial_code'=>'+966', 'timezone'=>'Asia/Riyadh', 'currency'=>'SAR'],
'SG' => ['name'=>'Singapore', 'iso2'=>'SG','iso3'=>'SGP','flag'=>$f('SG'),'dial_code'=>'+65', 'timezone'=>'Asia/Singapore', 'currency'=>'SGD'],
'KR' => ['name'=>'South Korea', 'iso2'=>'KR','iso3'=>'KOR','flag'=>$f('KR'),'dial_code'=>'+82', 'timezone'=>'Asia/Seoul', 'currency'=>'KRW'],
'LK' => ['name'=>'Sri Lanka', 'iso2'=>'LK','iso3'=>'LKA','flag'=>$f('LK'),'dial_code'=>'+94', 'timezone'=>'Asia/Colombo', 'currency'=>'LKR'],
'SY' => ['name'=>'Syria', 'iso2'=>'SY','iso3'=>'SYR','flag'=>$f('SY'),'dial_code'=>'+963', 'timezone'=>'Asia/Damascus', 'currency'=>'SYP'],
'TW' => ['name'=>'Taiwan', 'iso2'=>'TW','iso3'=>'TWN','flag'=>$f('TW'),'dial_code'=>'+886', 'timezone'=>'Asia/Taipei', 'currency'=>'TWD'],
'TJ' => ['name'=>'Tajikistan', 'iso2'=>'TJ','iso3'=>'TJK','flag'=>$f('TJ'),'dial_code'=>'+992', 'timezone'=>'Asia/Dushanbe', 'currency'=>'TJS'],
'TH' => ['name'=>'Thailand', 'iso2'=>'TH','iso3'=>'THA','flag'=>$f('TH'),'dial_code'=>'+66', 'timezone'=>'Asia/Bangkok', 'currency'=>'THB'],
'TL' => ['name'=>'Timor-Leste', 'iso2'=>'TL','iso3'=>'TLS','flag'=>$f('TL'),'dial_code'=>'+670', 'timezone'=>'Asia/Dili', 'currency'=>'USD'],
'TR' => ['name'=>'Turkey', 'iso2'=>'TR','iso3'=>'TUR','flag'=>$f('TR'),'dial_code'=>'+90', 'timezone'=>'Europe/Istanbul', 'currency'=>'TRY'],
'TM' => ['name'=>'Turkmenistan', 'iso2'=>'TM','iso3'=>'TKM','flag'=>$f('TM'),'dial_code'=>'+993', 'timezone'=>'Asia/Ashgabat', 'currency'=>'TMT'],
'AE' => ['name'=>'United Arab Emirates', 'iso2'=>'AE','iso3'=>'ARE','flag'=>$f('AE'),'dial_code'=>'+971', 'timezone'=>'Asia/Dubai', 'currency'=>'AED'],
'UZ' => ['name'=>'Uzbekistan', 'iso2'=>'UZ','iso3'=>'UZB','flag'=>$f('UZ'),'dial_code'=>'+998', 'timezone'=>'Asia/Tashkent', 'currency'=>'UZS'],
'VN' => ['name'=>'Vietnam', 'iso2'=>'VN','iso3'=>'VNM','flag'=>$f('VN'),'dial_code'=>'+84', 'timezone'=>'Asia/Ho_Chi_Minh', 'currency'=>'VND'],
'YE' => ['name'=>'Yemen', 'iso2'=>'YE','iso3'=>'YEM','flag'=>$f('YE'),'dial_code'=>'+967', 'timezone'=>'Asia/Aden', 'currency'=>'YER'],
// ── EUROPE ────────────────────────────────────────────────────────────
'AL' => ['name'=>'Albania', 'iso2'=>'AL','iso3'=>'ALB','flag'=>$f('AL'),'dial_code'=>'+355', 'timezone'=>'Europe/Tirane', 'currency'=>'ALL'],
'AD' => ['name'=>'Andorra', 'iso2'=>'AD','iso3'=>'AND','flag'=>$f('AD'),'dial_code'=>'+376', 'timezone'=>'Europe/Andorra', 'currency'=>'EUR'],
'AT' => ['name'=>'Austria', 'iso2'=>'AT','iso3'=>'AUT','flag'=>$f('AT'),'dial_code'=>'+43', 'timezone'=>'Europe/Vienna', 'currency'=>'EUR'],
'BY' => ['name'=>'Belarus', 'iso2'=>'BY','iso3'=>'BLR','flag'=>$f('BY'),'dial_code'=>'+375', 'timezone'=>'Europe/Minsk', 'currency'=>'BYN'],
'BE' => ['name'=>'Belgium', 'iso2'=>'BE','iso3'=>'BEL','flag'=>$f('BE'),'dial_code'=>'+32', 'timezone'=>'Europe/Brussels', 'currency'=>'EUR'],
'BA' => ['name'=>'Bosnia & Herzegovina', 'iso2'=>'BA','iso3'=>'BIH','flag'=>$f('BA'),'dial_code'=>'+387', 'timezone'=>'Europe/Sarajevo', 'currency'=>'BAM'],
'BG' => ['name'=>'Bulgaria', 'iso2'=>'BG','iso3'=>'BGR','flag'=>$f('BG'),'dial_code'=>'+359', 'timezone'=>'Europe/Sofia', 'currency'=>'BGN'],
'HR' => ['name'=>'Croatia', 'iso2'=>'HR','iso3'=>'HRV','flag'=>$f('HR'),'dial_code'=>'+385', 'timezone'=>'Europe/Zagreb', 'currency'=>'EUR'],
'CZ' => ['name'=>'Czech Republic', 'iso2'=>'CZ','iso3'=>'CZE','flag'=>$f('CZ'),'dial_code'=>'+420', 'timezone'=>'Europe/Prague', 'currency'=>'CZK'],
'DK' => ['name'=>'Denmark', 'iso2'=>'DK','iso3'=>'DNK','flag'=>$f('DK'),'dial_code'=>'+45', 'timezone'=>'Europe/Copenhagen', 'currency'=>'DKK'],
'EE' => ['name'=>'Estonia', 'iso2'=>'EE','iso3'=>'EST','flag'=>$f('EE'),'dial_code'=>'+372', 'timezone'=>'Europe/Tallinn', 'currency'=>'EUR'],
'FI' => ['name'=>'Finland', 'iso2'=>'FI','iso3'=>'FIN','flag'=>$f('FI'),'dial_code'=>'+358', 'timezone'=>'Europe/Helsinki', 'currency'=>'EUR'],
'FR' => ['name'=>'France', 'iso2'=>'FR','iso3'=>'FRA','flag'=>$f('FR'),'dial_code'=>'+33', 'timezone'=>'Europe/Paris', 'currency'=>'EUR'],
'DE' => ['name'=>'Germany', 'iso2'=>'DE','iso3'=>'DEU','flag'=>$f('DE'),'dial_code'=>'+49', 'timezone'=>'Europe/Berlin', 'currency'=>'EUR'],
'GR' => ['name'=>'Greece', 'iso2'=>'GR','iso3'=>'GRC','flag'=>$f('GR'),'dial_code'=>'+30', 'timezone'=>'Europe/Athens', 'currency'=>'EUR'],
'HU' => ['name'=>'Hungary', 'iso2'=>'HU','iso3'=>'HUN','flag'=>$f('HU'),'dial_code'=>'+36', 'timezone'=>'Europe/Budapest', 'currency'=>'HUF'],
'IS' => ['name'=>'Iceland', 'iso2'=>'IS','iso3'=>'ISL','flag'=>$f('IS'),'dial_code'=>'+354', 'timezone'=>'Atlantic/Reykjavik', 'currency'=>'ISK'],
'IE' => ['name'=>'Ireland', 'iso2'=>'IE','iso3'=>'IRL','flag'=>$f('IE'),'dial_code'=>'+353', 'timezone'=>'Europe/Dublin', 'currency'=>'EUR'],
'IT' => ['name'=>'Italy', 'iso2'=>'IT','iso3'=>'ITA','flag'=>$f('IT'),'dial_code'=>'+39', 'timezone'=>'Europe/Rome', 'currency'=>'EUR'],
'XK' => ['name'=>'Kosovo', 'iso2'=>'XK','iso3'=>'XKX','flag'=>$f('XK'),'dial_code'=>'+383', 'timezone'=>'Europe/Belgrade', 'currency'=>'EUR'],
'LV' => ['name'=>'Latvia', 'iso2'=>'LV','iso3'=>'LVA','flag'=>$f('LV'),'dial_code'=>'+371', 'timezone'=>'Europe/Riga', 'currency'=>'EUR'],
'LI' => ['name'=>'Liechtenstein', 'iso2'=>'LI','iso3'=>'LIE','flag'=>$f('LI'),'dial_code'=>'+423', 'timezone'=>'Europe/Vaduz', 'currency'=>'CHF'],
'LT' => ['name'=>'Lithuania', 'iso2'=>'LT','iso3'=>'LTU','flag'=>$f('LT'),'dial_code'=>'+370', 'timezone'=>'Europe/Vilnius', 'currency'=>'EUR'],
'LU' => ['name'=>'Luxembourg', 'iso2'=>'LU','iso3'=>'LUX','flag'=>$f('LU'),'dial_code'=>'+352', 'timezone'=>'Europe/Luxembourg', 'currency'=>'EUR'],
'MT' => ['name'=>'Malta', 'iso2'=>'MT','iso3'=>'MLT','flag'=>$f('MT'),'dial_code'=>'+356', 'timezone'=>'Europe/Malta', 'currency'=>'EUR'],
'MD' => ['name'=>'Moldova', 'iso2'=>'MD','iso3'=>'MDA','flag'=>$f('MD'),'dial_code'=>'+373', 'timezone'=>'Europe/Chisinau', 'currency'=>'MDL'],
'MC' => ['name'=>'Monaco', 'iso2'=>'MC','iso3'=>'MCO','flag'=>$f('MC'),'dial_code'=>'+377', 'timezone'=>'Europe/Monaco', 'currency'=>'EUR'],
'ME' => ['name'=>'Montenegro', 'iso2'=>'ME','iso3'=>'MNE','flag'=>$f('ME'),'dial_code'=>'+382', 'timezone'=>'Europe/Podgorica', 'currency'=>'EUR'],
'NL' => ['name'=>'Netherlands', 'iso2'=>'NL','iso3'=>'NLD','flag'=>$f('NL'),'dial_code'=>'+31', 'timezone'=>'Europe/Amsterdam', 'currency'=>'EUR'],
'MK' => ['name'=>'North Macedonia', 'iso2'=>'MK','iso3'=>'MKD','flag'=>$f('MK'),'dial_code'=>'+389', 'timezone'=>'Europe/Skopje', 'currency'=>'MKD'],
'NO' => ['name'=>'Norway', 'iso2'=>'NO','iso3'=>'NOR','flag'=>$f('NO'),'dial_code'=>'+47', 'timezone'=>'Europe/Oslo', 'currency'=>'NOK'],
'PL' => ['name'=>'Poland', 'iso2'=>'PL','iso3'=>'POL','flag'=>$f('PL'),'dial_code'=>'+48', 'timezone'=>'Europe/Warsaw', 'currency'=>'PLN'],
'PT' => ['name'=>'Portugal', 'iso2'=>'PT','iso3'=>'PRT','flag'=>$f('PT'),'dial_code'=>'+351', 'timezone'=>'Europe/Lisbon', 'currency'=>'EUR'],
'RO' => ['name'=>'Romania', 'iso2'=>'RO','iso3'=>'ROU','flag'=>$f('RO'),'dial_code'=>'+40', 'timezone'=>'Europe/Bucharest', 'currency'=>'RON'],
'RU' => ['name'=>'Russia', 'iso2'=>'RU','iso3'=>'RUS','flag'=>$f('RU'),'dial_code'=>'+7', 'timezone'=>'Europe/Moscow', 'currency'=>'RUB'],
'SM' => ['name'=>'San Marino', 'iso2'=>'SM','iso3'=>'SMR','flag'=>$f('SM'),'dial_code'=>'+378', 'timezone'=>'Europe/San_Marino', 'currency'=>'EUR'],
'RS' => ['name'=>'Serbia', 'iso2'=>'RS','iso3'=>'SRB','flag'=>$f('RS'),'dial_code'=>'+381', 'timezone'=>'Europe/Belgrade', 'currency'=>'RSD'],
'SK' => ['name'=>'Slovakia', 'iso2'=>'SK','iso3'=>'SVK','flag'=>$f('SK'),'dial_code'=>'+421', 'timezone'=>'Europe/Bratislava', 'currency'=>'EUR'],
'SI' => ['name'=>'Slovenia', 'iso2'=>'SI','iso3'=>'SVN','flag'=>$f('SI'),'dial_code'=>'+386', 'timezone'=>'Europe/Ljubljana', 'currency'=>'EUR'],
'ES' => ['name'=>'Spain', 'iso2'=>'ES','iso3'=>'ESP','flag'=>$f('ES'),'dial_code'=>'+34', 'timezone'=>'Europe/Madrid', 'currency'=>'EUR'],
'SE' => ['name'=>'Sweden', 'iso2'=>'SE','iso3'=>'SWE','flag'=>$f('SE'),'dial_code'=>'+46', 'timezone'=>'Europe/Stockholm', 'currency'=>'SEK'],
'CH' => ['name'=>'Switzerland', 'iso2'=>'CH','iso3'=>'CHE','flag'=>$f('CH'),'dial_code'=>'+41', 'timezone'=>'Europe/Zurich', 'currency'=>'CHF'],
'UA' => ['name'=>'Ukraine', 'iso2'=>'UA','iso3'=>'UKR','flag'=>$f('UA'),'dial_code'=>'+380', 'timezone'=>'Europe/Kyiv', 'currency'=>'UAH'],
'GB' => ['name'=>'United Kingdom', 'iso2'=>'GB','iso3'=>'GBR','flag'=>$f('GB'),'dial_code'=>'+44', 'timezone'=>'Europe/London', 'currency'=>'GBP'],
'VA' => ['name'=>'Vatican City', 'iso2'=>'VA','iso3'=>'VAT','flag'=>$f('VA'),'dial_code'=>'+39', 'timezone'=>'Europe/Vatican', 'currency'=>'EUR'],
// ── OCEANIA ───────────────────────────────────────────────────────────
'AU' => ['name'=>'Australia', 'iso2'=>'AU','iso3'=>'AUS','flag'=>$f('AU'),'dial_code'=>'+61', 'timezone'=>'Australia/Sydney', 'currency'=>'AUD'],
'FJ' => ['name'=>'Fiji', 'iso2'=>'FJ','iso3'=>'FJI','flag'=>$f('FJ'),'dial_code'=>'+679', 'timezone'=>'Pacific/Fiji', 'currency'=>'FJD'],
'KI' => ['name'=>'Kiribati', 'iso2'=>'KI','iso3'=>'KIR','flag'=>$f('KI'),'dial_code'=>'+686', 'timezone'=>'Pacific/Tarawa', 'currency'=>'AUD'],
'MH' => ['name'=>'Marshall Islands', 'iso2'=>'MH','iso3'=>'MHL','flag'=>$f('MH'),'dial_code'=>'+692', 'timezone'=>'Pacific/Majuro', 'currency'=>'USD'],
'FM' => ['name'=>'Micronesia', 'iso2'=>'FM','iso3'=>'FSM','flag'=>$f('FM'),'dial_code'=>'+691', 'timezone'=>'Pacific/Pohnpei', 'currency'=>'USD'],
'NR' => ['name'=>'Nauru', 'iso2'=>'NR','iso3'=>'NRU','flag'=>$f('NR'),'dial_code'=>'+674', 'timezone'=>'Pacific/Nauru', 'currency'=>'AUD'],
'NZ' => ['name'=>'New Zealand', 'iso2'=>'NZ','iso3'=>'NZL','flag'=>$f('NZ'),'dial_code'=>'+64', 'timezone'=>'Pacific/Auckland', 'currency'=>'NZD'],
'PW' => ['name'=>'Palau', 'iso2'=>'PW','iso3'=>'PLW','flag'=>$f('PW'),'dial_code'=>'+680', 'timezone'=>'Pacific/Palau', 'currency'=>'USD'],
'PG' => ['name'=>'Papua New Guinea', 'iso2'=>'PG','iso3'=>'PNG','flag'=>$f('PG'),'dial_code'=>'+675', 'timezone'=>'Pacific/Port_Moresby', 'currency'=>'PGK'],
'WS' => ['name'=>'Samoa', 'iso2'=>'WS','iso3'=>'WSM','flag'=>$f('WS'),'dial_code'=>'+685', 'timezone'=>'Pacific/Apia', 'currency'=>'WST'],
'SB' => ['name'=>'Solomon Islands', 'iso2'=>'SB','iso3'=>'SLB','flag'=>$f('SB'),'dial_code'=>'+677', 'timezone'=>'Pacific/Guadalcanal', 'currency'=>'SBD'],
'TO' => ['name'=>'Tonga', 'iso2'=>'TO','iso3'=>'TON','flag'=>$f('TO'),'dial_code'=>'+676', 'timezone'=>'Pacific/Tongatapu', 'currency'=>'TOP'],
'TV' => ['name'=>'Tuvalu', 'iso2'=>'TV','iso3'=>'TUV','flag'=>$f('TV'),'dial_code'=>'+688', 'timezone'=>'Pacific/Funafuti', 'currency'=>'AUD'],
'VU' => ['name'=>'Vanuatu', 'iso2'=>'VU','iso3'=>'VUT','flag'=>$f('VU'),'dial_code'=>'+678', 'timezone'=>'Pacific/Efate', 'currency'=>'VUV'],
];
}
/**
* Options list for the phone-code component.
* Returns [value, flag, label, secondary, search] per entry, sorted by country name.
*/
public static function forPhoneCode(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['dial_code'] . '|' . $country['iso2'],
'flag' => $country['flag'],
'label' => $country['dial_code'],
'secondary' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['dial_code'] . ' ' . $country['iso2']),
];
}
usort($list, fn($a, $b) => strcmp($a['secondary'], $b['secondary']));
return $list;
}
/**
* Options list for the country/nationality component.
* value = iso2 code.
*/
public static function forCountry(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['iso2'],
'flag' => $country['flag'],
'label' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['iso2'] . ' ' . $country['iso3']),
];
}
usort($list, fn($a, $b) => strcmp($a['label'], $b['label']));
return $list;
}
/**
* Options list for the timezone component.
* Reads PHP's built-in timezone database and attaches country flags.
*/
public static function forTimezone(): array
{
$countries = self::all();
$list = [];
// Skip Etc/ offsets and legacy backward-compat entries; keep named timezones
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true)) {
continue;
}
if (!str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$dt = new \DateTime('now', $dtze);
$offset = $dt->getOffset();
$h = (int) floor(abs($offset) / 3600);
$m = (int) (abs($offset) % 3600 / 60);
$sign = $offset >= 0 ? '+' : '-';
$utc = 'UTC' . $sign . str_pad($h, 2, '0', STR_PAD_LEFT) . ':' . str_pad($m, 2, '0', STR_PAD_LEFT);
$location = $dtze->getLocation();
$countryCode = strtoupper($location['country_code'] ?? '');
$countryData = $countries[$countryCode] ?? null;
$flag = $countryData ? $countryData['flag'] : '';
$countryName = $countryData ? $countryData['name'] : '';
$parts = explode('/', $tz);
$city = str_replace('_', ' ', end($parts));
$list[] = [
'value' => $tz,
'flag' => $flag,
'label' => str_replace('_', ' ', $tz),
'utc' => $utc,
'offset' => $offset,
'search' => strtolower($tz . ' ' . $city . ' ' . $utc . ' ' . $countryName),
];
} catch (\Exception) {
continue;
}
}
usort($list, fn($a, $b) => $a['offset'] <=> $b['offset'] ?: strcmp($a['value'], $b['value']));
return $list;
}
/**
* Returns a map of IANA timezone identifier ISO2 country code.
* Used by the country-select component to auto-detect the user's country from their device timezone.
*/
public static function forGeoTimezoneMap(): array
{
$countries = self::all();
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
$map = [];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true) || !str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$loc = $dtze->getLocation();
$iso2 = strtoupper($loc['country_code'] ?? '');
if ($iso2 && isset($countries[$iso2])) {
$map[$tz] = $iso2;
}
} catch (\Exception) {}
}
return $map;
}
}

View File

@ -1,152 +0,0 @@
<?php
namespace App\Data;
class Languages
{
/**
* All supported languages keyed by ISO 639-1 code.
* Fields: name (English), native (native script), code (uppercase ISO), flag (ISO 3166-1 alpha-2 lowercase for flag-icons)
*/
public static function all(): array
{
return [
// ── HIGH-PRIORITY (Middle East / platform focus) ────────────────────
'ar' => ['name' => 'Arabic', 'native' => 'العربية', 'code' => 'AR', 'flag' => 'sa'],
'en' => ['name' => 'English', 'native' => 'English', 'code' => 'EN', 'flag' => 'gb'],
'fa' => ['name' => 'Persian', 'native' => 'فارسی', 'code' => 'FA', 'flag' => 'ir'],
'ur' => ['name' => 'Urdu', 'native' => 'اردو', 'code' => 'UR', 'flag' => 'pk'],
'tr' => ['name' => 'Turkish', 'native' => 'Türkçe', 'code' => 'TR', 'flag' => 'tr'],
'ku' => ['name' => 'Kurdish', 'native' => 'Kurdî', 'code' => 'KU', 'flag' => 'iq'],
// ── SOUTH ASIA ───────────────────────────────────────────────────────
'hi' => ['name' => 'Hindi', 'native' => 'हिन्दी', 'code' => 'HI', 'flag' => 'in'],
'bn' => ['name' => 'Bengali', 'native' => 'বাংলা', 'code' => 'BN', 'flag' => 'bd'],
'ta' => ['name' => 'Tamil', 'native' => 'தமிழ்', 'code' => 'TA', 'flag' => 'lk'],
'te' => ['name' => 'Telugu', 'native' => 'తెలుగు', 'code' => 'TE', 'flag' => 'in'],
'ml' => ['name' => 'Malayalam', 'native' => 'മലയാളം', 'code' => 'ML', 'flag' => 'in'],
'si' => ['name' => 'Sinhala', 'native' => 'සිංහල', 'code' => 'SI', 'flag' => 'lk'],
'ne' => ['name' => 'Nepali', 'native' => 'नेपाली', 'code' => 'NE', 'flag' => 'np'],
// ── EAST ASIA ────────────────────────────────────────────────────────
'zh' => ['name' => 'Chinese', 'native' => '中文', 'code' => 'ZH', 'flag' => 'cn'],
'ja' => ['name' => 'Japanese', 'native' => '日本語', 'code' => 'JA', 'flag' => 'jp'],
'ko' => ['name' => 'Korean', 'native' => '한국어', 'code' => 'KO', 'flag' => 'kr'],
// ── SOUTHEAST ASIA ───────────────────────────────────────────────────
'id' => ['name' => 'Indonesian', 'native' => 'Bahasa Indonesia', 'code' => 'ID', 'flag' => 'id'],
'ms' => ['name' => 'Malay', 'native' => 'Bahasa Melayu', 'code' => 'MS', 'flag' => 'my'],
'tl' => ['name' => 'Tagalog', 'native' => 'Filipino', 'code' => 'TL', 'flag' => 'ph'],
'th' => ['name' => 'Thai', 'native' => 'ภาษาไทย', 'code' => 'TH', 'flag' => 'th'],
'vi' => ['name' => 'Vietnamese', 'native' => 'Tiếng Việt', 'code' => 'VI', 'flag' => 'vn'],
'my' => ['name' => 'Burmese', 'native' => 'မြန်မာဘာသာ', 'code' => 'MY', 'flag' => 'mm'],
'km' => ['name' => 'Khmer', 'native' => 'ភាសាខ្មែរ', 'code' => 'KM', 'flag' => 'kh'],
// ── CENTRAL ASIA ─────────────────────────────────────────────────────
'az' => ['name' => 'Azerbaijani', 'native' => 'Azərbaycan', 'code' => 'AZ', 'flag' => 'az'],
'kk' => ['name' => 'Kazakh', 'native' => 'Қазақша', 'code' => 'KK', 'flag' => 'kz'],
'uz' => ['name' => 'Uzbek', 'native' => "O'zbek", 'code' => 'UZ', 'flag' => 'uz'],
'tg' => ['name' => 'Tajik', 'native' => 'Тоҷикӣ', 'code' => 'TG', 'flag' => 'tj'],
'tk' => ['name' => 'Turkmen', 'native' => 'Türkmençe', 'code' => 'TK', 'flag' => 'tm'],
'ky' => ['name' => 'Kyrgyz', 'native' => 'Кыргызча', 'code' => 'KY', 'flag' => 'kg'],
// ── EUROPE (ROMANCE) ─────────────────────────────────────────────────
'fr' => ['name' => 'French', 'native' => 'Français', 'code' => 'FR', 'flag' => 'fr'],
'es' => ['name' => 'Spanish', 'native' => 'Español', 'code' => 'ES', 'flag' => 'es'],
'pt' => ['name' => 'Portuguese', 'native' => 'Português', 'code' => 'PT', 'flag' => 'pt'],
'it' => ['name' => 'Italian', 'native' => 'Italiano', 'code' => 'IT', 'flag' => 'it'],
'ro' => ['name' => 'Romanian', 'native' => 'Română', 'code' => 'RO', 'flag' => 'ro'],
'ca' => ['name' => 'Catalan', 'native' => 'Català', 'code' => 'CA', 'flag' => 'es'],
// ── EUROPE (GERMANIC) ────────────────────────────────────────────────
'de' => ['name' => 'German', 'native' => 'Deutsch', 'code' => 'DE', 'flag' => 'de'],
'nl' => ['name' => 'Dutch', 'native' => 'Nederlands', 'code' => 'NL', 'flag' => 'nl'],
'sv' => ['name' => 'Swedish', 'native' => 'Svenska', 'code' => 'SV', 'flag' => 'se'],
'no' => ['name' => 'Norwegian', 'native' => 'Norsk', 'code' => 'NO', 'flag' => 'no'],
'da' => ['name' => 'Danish', 'native' => 'Dansk', 'code' => 'DA', 'flag' => 'dk'],
'fi' => ['name' => 'Finnish', 'native' => 'Suomi', 'code' => 'FI', 'flag' => 'fi'],
'is' => ['name' => 'Icelandic', 'native' => 'Íslenska', 'code' => 'IS', 'flag' => 'is'],
// ── EUROPE (SLAVIC) ──────────────────────────────────────────────────
'ru' => ['name' => 'Russian', 'native' => 'Русский', 'code' => 'RU', 'flag' => 'ru'],
'uk' => ['name' => 'Ukrainian', 'native' => 'Українська', 'code' => 'UK', 'flag' => 'ua'],
'pl' => ['name' => 'Polish', 'native' => 'Polski', 'code' => 'PL', 'flag' => 'pl'],
'cs' => ['name' => 'Czech', 'native' => 'Čeština', 'code' => 'CS', 'flag' => 'cz'],
'sk' => ['name' => 'Slovak', 'native' => 'Slovenčina', 'code' => 'SK', 'flag' => 'sk'],
'hr' => ['name' => 'Croatian', 'native' => 'Hrvatski', 'code' => 'HR', 'flag' => 'hr'],
'sr' => ['name' => 'Serbian', 'native' => 'Srpski', 'code' => 'SR', 'flag' => 'rs'],
'bg' => ['name' => 'Bulgarian', 'native' => 'Български', 'code' => 'BG', 'flag' => 'bg'],
'sl' => ['name' => 'Slovenian', 'native' => 'Slovenščina', 'code' => 'SL', 'flag' => 'si'],
'mk' => ['name' => 'Macedonian', 'native' => 'Македонски', 'code' => 'MK', 'flag' => 'mk'],
// ── EUROPE (OTHER) ───────────────────────────────────────────────────
'el' => ['name' => 'Greek', 'native' => 'Ελληνικά', 'code' => 'EL', 'flag' => 'gr'],
'hu' => ['name' => 'Hungarian', 'native' => 'Magyar', 'code' => 'HU', 'flag' => 'hu'],
'he' => ['name' => 'Hebrew', 'native' => 'עברית', 'code' => 'HE', 'flag' => 'il'],
'lt' => ['name' => 'Lithuanian', 'native' => 'Lietuvių', 'code' => 'LT', 'flag' => 'lt'],
'lv' => ['name' => 'Latvian', 'native' => 'Latviešu', 'code' => 'LV', 'flag' => 'lv'],
'et' => ['name' => 'Estonian', 'native' => 'Eesti', 'code' => 'ET', 'flag' => 'ee'],
'sq' => ['name' => 'Albanian', 'native' => 'Shqip', 'code' => 'SQ', 'flag' => 'al'],
// ── AFRICA ───────────────────────────────────────────────────────────
'sw' => ['name' => 'Swahili', 'native' => 'Kiswahili', 'code' => 'SW', 'flag' => 'tz'],
'am' => ['name' => 'Amharic', 'native' => 'አማርኛ', 'code' => 'AM', 'flag' => 'et'],
'so' => ['name' => 'Somali', 'native' => 'Soomaali', 'code' => 'SO', 'flag' => 'so'],
'ha' => ['name' => 'Hausa', 'native' => 'Hausa', 'code' => 'HA', 'flag' => 'ng'],
'yo' => ['name' => 'Yoruba', 'native' => 'Yorùbá', 'code' => 'YO', 'flag' => 'ng'],
'ig' => ['name' => 'Igbo', 'native' => 'Igbo', 'code' => 'IG', 'flag' => 'ng'],
'zu' => ['name' => 'Zulu', 'native' => 'isiZulu', 'code' => 'ZU', 'flag' => 'za'],
'af' => ['name' => 'Afrikaans', 'native' => 'Afrikaans', 'code' => 'AF', 'flag' => 'za'],
// ── AMERICAS ─────────────────────────────────────────────────────────
'qu' => ['name' => 'Quechua', 'native' => 'Runa Simi', 'code' => 'QU', 'flag' => 'pe'],
];
}
/**
* Return the flag icon code (lowercase ISO 3166-1 alpha-2) for a given ISO 639-1 language code.
* Returns null when the code is unknown or empty.
*/
public static function flag(?string $iso639): ?string
{
if (!$iso639) return null;
return self::all()[strtolower($iso639)]['flag'] ?? null;
}
/**
* Options list for the language-select component.
* value = ISO 639-1 code (e.g. "ar", "en").
* Arabic and English are pinned to the top; everything else is sorted alphabetically by English name.
*/
public static function forLanguage(): array
{
$pinned = ['ar', 'en'];
$all = self::all();
$list = [];
foreach ($all as $iso => $lang) {
$list[] = [
'value' => $iso,
'code' => $lang['code'],
'flag' => $lang['flag'],
'label' => $lang['name'],
'native' => $lang['native'],
'search' => strtolower($iso . ' ' . $lang['name'] . ' ' . $lang['native']),
'pinned' => in_array($iso, $pinned, true),
];
}
usort($list, function ($a, $b) use ($pinned) {
$aPin = array_search($a['value'], $pinned, true);
$bPin = array_search($b['value'], $pinned, true);
if ($aPin !== false && $bPin !== false) return $aPin <=> $bPin;
if ($aPin !== false) return -1;
if ($bPin !== false) return 1;
return strcmp($a['label'], $b['label']);
});
return $list;
}
}

View File

@ -3,32 +3,28 @@
namespace App\Exceptions; namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable; use Throwable;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [ protected $dontFlash = [
'current_password', 'current_password',
'password', 'password',
'password_confirmation', 'password_confirmation',
]; ];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void public function register(): void
{ {
$this->reportable(function (Throwable $e) { $this->reportable(function (Throwable $e) {
// //
}); });
// Redirect to home with a toast when a video URL can't be resolved
$this->renderable(function (NotFoundHttpException $e, $request) {
$prev = $e->getPrevious();
$isVideoRoute = str_starts_with($request->path(), 'videos/');
if ($isVideoRoute && ($prev instanceof ModelNotFoundException || $e->getMessage() === '')) {
return redirect('/')->with('toast_error', 'This video is no longer available.');
}
});
} }
} }

View File

@ -1,101 +0,0 @@
<?php
namespace App\Helpers;
class Horoscope
{
private static array $signs = [
['name' => 'Capricorn', 'symbol' => '♑', 'element' => 'Earth', 'emoji' => '🐐', 'from' => [12, 22], 'to' => [1, 19],
'traits' => ['Ambitious', 'Disciplined', 'Patient', 'Practical']],
['name' => 'Aquarius', 'symbol' => '♒', 'element' => 'Air', 'emoji' => '🏺', 'from' => [1, 20], 'to' => [2, 18],
'traits' => ['Independent', 'Progressive', 'Humanitarian', 'Inventive']],
['name' => 'Pisces', 'symbol' => '♓', 'element' => 'Water', 'emoji' => '🐟', 'from' => [2, 19], 'to' => [3, 20],
'traits' => ['Empathetic', 'Creative', 'Intuitive', 'Gentle']],
['name' => 'Aries', 'symbol' => '♈', 'element' => 'Fire', 'emoji' => '🐏', 'from' => [3, 21], 'to' => [4, 19],
'traits' => ['Courageous', 'Energetic', 'Enthusiastic', 'Bold']],
['name' => 'Taurus', 'symbol' => '♉', 'element' => 'Earth', 'emoji' => '🐂', 'from' => [4, 20], 'to' => [5, 20],
'traits' => ['Reliable', 'Patient', 'Devoted', 'Sensual']],
['name' => 'Gemini', 'symbol' => '♊', 'element' => 'Air', 'emoji' => '👥', 'from' => [5, 21], 'to' => [6, 20],
'traits' => ['Adaptable', 'Curious', 'Witty', 'Expressive']],
['name' => 'Cancer', 'symbol' => '♋', 'element' => 'Water', 'emoji' => '🦀', 'from' => [6, 21], 'to' => [7, 22],
'traits' => ['Caring', 'Protective', 'Intuitive', 'Emotional']],
['name' => 'Leo', 'symbol' => '♌', 'element' => 'Fire', 'emoji' => '🦁', 'from' => [7, 23], 'to' => [8, 22],
'traits' => ['Confident', 'Dramatic', 'Creative', 'Generous']],
['name' => 'Virgo', 'symbol' => '♍', 'element' => 'Earth', 'emoji' => '🌾', 'from' => [8, 23], 'to' => [9, 22],
'traits' => ['Analytical', 'Loyal', 'Practical', 'Diligent']],
['name' => 'Libra', 'symbol' => '♎', 'element' => 'Air', 'emoji' => '⚖️', 'from' => [9, 23], 'to' => [10, 22],
'traits' => ['Diplomatic', 'Gracious', 'Fair-minded', 'Social']],
['name' => 'Scorpio', 'symbol' => '♏', 'element' => 'Water', 'emoji' => '🦂', 'from' => [10, 23], 'to' => [11, 21],
'traits' => ['Passionate', 'Stubborn', 'Resourceful', 'Brave']],
['name' => 'Sagittarius','symbol' => '♐', 'element' => 'Fire', 'emoji' => '🏹', 'from' => [11, 22], 'to' => [12, 21],
'traits' => ['Optimistic', 'Adventurous', 'Honest', 'Philosophical']],
];
private static array $elementCompatibility = [
'Fire' => ['Fire' => 85, 'Air' => 80, 'Earth' => 45, 'Water' => 40],
'Earth' => ['Earth' => 88, 'Water' => 82, 'Fire' => 45, 'Air' => 50],
'Air' => ['Air' => 84, 'Fire' => 80, 'Water' => 48, 'Earth' => 50],
'Water' => ['Water' => 86, 'Earth' => 82, 'Fire' => 40, 'Air' => 48],
];
public static function getSign(?string $birthday): ?array
{
if (! $birthday) return null;
try {
$date = new \DateTime($birthday);
} catch (\Exception) {
return null;
}
$month = (int) $date->format('n');
$day = (int) $date->format('j');
foreach (self::$signs as $sign) {
[$fm, $fd] = $sign['from'];
[$tm, $td] = $sign['to'];
if ($fm > $tm) {
// Capricorn wraps year
if (($month === $fm && $day >= $fd) || ($month === $tm && $day <= $td)) {
return $sign;
}
} else {
if (($month === $fm && $day >= $fd) || ($month > $fm && $month < $tm) || ($month === $tm && $day <= $td)) {
return $sign;
}
}
}
return null;
}
public static function compatibility(?array $sign1, ?array $sign2): ?int
{
if (! $sign1 || ! $sign2) return null;
$base = self::$elementCompatibility[$sign1['element']][$sign2['element']] ?? 50;
// Same sign bonus
if ($sign1['name'] === $sign2['name']) {
$base = min(98, $base + 8);
}
// Deterministic jitter so different sign pairs within same element feel unique
$seed = crc32($sign1['name'] . ':' . $sign2['name']);
$jitter = (abs($seed) % 11) - 5;
return max(20, min(99, $base + $jitter));
}
public static function elementColor(string $element): string
{
return match ($element) {
'Fire' => '#ff6b35',
'Earth' => '#6abf69',
'Air' => '#64b5f6',
'Water' => '#4fc3f7',
default => '#aaa',
};
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -24,32 +23,10 @@ class AuthenticatedSessionController extends Controller
$remember = $request->filled('remember'); $remember = $request->filled('remember');
if (Auth::attempt($credentials, $remember)) { if (Auth::attempt($credentials, $remember)) {
$user = Auth::user();
if ($user->two_factor_enabled && $user->two_factor_secret) {
Auth::logout();
$request->session()->put('2fa_user_id', $user->id);
$request->session()->put('2fa_remember', $remember);
return redirect()->route('2fa.challenge');
}
$request->session()->regenerate(); $request->session()->regenerate();
AuditLog::record('user.login', [
'user_id' => $user->id,
'user_name' => $user->name,
'details' => ['email' => $user->email],
]);
return redirect()->intended('/videos'); return redirect()->intended('/videos');
} }
AuditLog::record('user.login.failed', [
'user_id' => null,
'user_name' => null,
'details' => ['email' => $credentials['email']],
]);
return back()->withErrors([ return back()->withErrors([
'email' => 'The provided credentials do not match our records.', 'email' => 'The provided credentials do not match our records.',
]); ]);
@ -57,13 +34,6 @@ class AuthenticatedSessionController extends Controller
public function destroy(Request $request) public function destroy(Request $request)
{ {
$user = Auth::user();
if ($user) {
AuditLog::record('user.logout', [
'user_id' => $user->id,
'user_name' => $user->name,
]);
}
Auth::logout(); Auth::logout();
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();

View File

@ -4,8 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Notifications\NewUserRegistered;
use App\Rules\NotDisposableEmail;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -20,39 +18,22 @@ class RegisteredUserController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
// Honeypot — bots fill hidden fields; real users never do
if ($request->filled('_hp')) {
// Silently appear to succeed to confuse the bot
return redirect()->route('login');
}
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()], 'password' => ['required', 'confirmed', Password::defaults()],
'birthday' => ['required', 'date', 'before:today'],
'gender' => ['required', 'in:male,female'],
'nationality' => ['required', 'string', 'size:2'],
]); ]);
$user = User::create([ $user = User::create([
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'birthday' => $request->birthday,
'gender' => $request->gender,
'nationality' => $request->nationality,
]); ]);
event(new Registered($user)); event(new Registered($user));
// Notify all super admins of the new registration
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
$admin->notify(new NewUserRegistered($user));
});
auth()->login($user); auth()->login($user);
return redirect()->route('verification.notice'); return redirect('/videos');
} }
} }

View File

@ -3,11 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Comment; use App\Models\Comment;
use App\Models\CommentLike;
use App\Models\Video; use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -21,45 +17,30 @@ class CommentController extends Controller
public function index(Video $video) public function index(Video $video)
{ {
$comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get(); $comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get();
return response()->json($comments); return response()->json($comments);
} }
public function store(Request $request, Video $video) public function store(Request $request, Video $video)
{ {
$request->validate([ $request->validate([
'body' => 'required|string|max:1000', 'body' => 'required|string|max:1000',
'parent_id' => 'nullable|exists:comments,id', 'parent_id' => 'nullable|exists:comments,id',
]); ]);
$commenter = Auth::user();
$comment = $video->comments()->create([ $comment = $video->comments()->create([
'user_id' => $commenter->id, 'user_id' => Auth::id(),
'body' => $request->body, 'body' => $request->body,
'parent_id' => $request->parent_id, 'parent_id' => $request->parent_id,
]); ]);
$comment->load('user:id,name,avatar'); // Handle mentions
preg_match_all('/@(\w+)/', $request->body, $matches);
// Fire notifications (never notify yourself) if (!empty($matches[1])) {
if ($request->parent_id) { // Mentions found - in production, you would send notifications here
// Reply — notify the parent comment's author // For now, we just parse them
$parent = Comment::find($request->parent_id);
if ($parent && $parent->user_id !== $commenter->id) {
$parent->user->notify(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));
}
} }
return response()->json([ return response()->json($comment->load('user'));
'success' => true,
'comment' => $comment->load('user:id,name,avatar'),
]);
} }
public function update(Request $request, Comment $comment) public function update(Request $request, Comment $comment)
@ -72,10 +53,11 @@ class CommentController extends Controller
'body' => 'required|string|max:1000', 'body' => 'required|string|max:1000',
]); ]);
$comment->update(['body' => $request->body]); $comment->update([
$comment->load('user:id,name,avatar'); 'body' => $request->body,
]);
return response()->json($comment->load('user:id,name,avatar')); return response()->json($comment->load('user'));
} }
public function destroy(Comment $comment) public function destroy(Comment $comment)
@ -85,37 +67,6 @@ class CommentController extends Controller
} }
$comment->delete(); $comment->delete();
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
public function like(Comment $comment)
{
$userId = Auth::id();
$existing = CommentLike::where('comment_id', $comment->id)
->where('user_id', $userId)
->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
CommentLike::create(['comment_id' => $comment->id, 'user_id' => $userId]);
$liked = true;
// Notify the comment author (not self-likes)
if ($comment->user_id !== $userId) {
$video = $comment->video;
if ($video) {
$comment->user->notify(
new NewCommentLikeNotification($video, $comment, Auth::user())
);
}
}
}
$count = CommentLike::where('comment_id', $comment->id)->count();
return response()->json(['liked' => $liked, 'count' => $count]);
}
} }

View File

@ -9,29 +9,4 @@ use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController class Controller extends BaseController
{ {
use AuthorizesRequests, ValidatesRequests; use AuthorizesRequests, ValidatesRequests;
/**
* Generate a collision-safe filename using the authenticated user's name,
* a compact timestamp with millisecond precision, and the original extension.
*
* Format: {username}_{YYYYMMDD}_{HHmmss}_{ms}.{ext}
* Example: ghassanyusuf_20260301_120102_020.mp4
*/
protected static function generateFilename(string $extension, ?string $username = null): string
{
$user = $username ?? (auth()->user()?->name ?? 'user');
// Sanitize: lowercase, keep only alphanumeric, collapse to max 20 chars
$slug = preg_replace('/[^a-z0-9]/', '', strtolower($user));
$slug = substr($slug ?: 'user', 0, 20);
$now = now();
$ms = str_pad((int) ($now->microsecond / 1000), 3, '0', STR_PAD_LEFT);
$timestamp = $now->format('Ymd') . '_' . $now->format('His') . '_' . $ms;
$ext = ltrim(strtolower($extension), '.');
return "{$slug}_{$timestamp}.{$ext}";
}
} }

View File

@ -1,45 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ImageUploadController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'image' => 'required|string',
'folder' => 'required|string|max:80',
'filename' => 'required|string|max:200',
]);
$imageData = $request->image;
$parts = explode(';base64,', $imageData);
$typePart = explode('image/', $parts[0]);
$extension = $typePart[1] ?? 'png';
// Sanitize extension
$extension = preg_replace('/[^a-z0-9]/', '', strtolower($extension));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'])) {
$extension = 'png';
}
$imageBinary = base64_decode($parts[1] ?? '');
if (!$imageBinary) {
return response()->json(['success' => false, 'message' => 'Invalid image data'], 422);
}
$folder = trim($request->folder, '/');
$fileName = $request->filename . '.' . $extension;
$fullPath = $folder . '/' . $fileName;
Storage::disk('public')->put($fullPath, $imageBinary);
return response()->json([
'success' => true,
'path' => $fullPath,
'url' => asset('storage/' . $fullPath),
]);
}
}

View File

@ -1,294 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\CoachReview;
use App\Models\MatchPoint;
use App\Models\MatchRound;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MatchEventController extends Controller
{
// ==================== ROUNDS ====================
public function storeRound(Request $request, Video $video)
{
$request->validate([
'round_number' => 'required|integer|min:1',
'name' => 'nullable|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round = MatchRound::create([
'video_id' => $video->id,
'round_number' => $request->round_number,
'name' => $request->name ?? 'ROUND '.$request->round_number,
'start_time_seconds' => $request->start_time_seconds,
]);
return response()->json([
'success' => true,
'round' => $round,
'message' => 'Round added successfully!',
]);
}
public function updateRound(Request $request, MatchRound $round)
{
$request->validate([
'round_number' => 'sometimes|integer|min:1',
'name' => 'required|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0',
]);
// Check if user owns the video
if (Auth::id() !== $round->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round->update([
'round_number' => $request->round_number ?? $round->round_number,
'name' => $request->name,
'start_time_seconds' => $request->start_time_seconds,
]);
return response()->json([
'success' => true,
'round' => $round,
'message' => 'Round updated successfully!',
]);
}
public function destroyRound(MatchRound $round)
{
// Check if user owns the video
if (Auth::id() !== $round->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round->delete();
return response()->json([
'success' => true,
'message' => 'Round deleted successfully!',
]);
}
// ==================== POINTS ====================
public function storePoint(Request $request, Video $video)
{
$request->validate([
'round_id' => 'required|exists:match_rounds,id',
'timestamp_seconds' => 'required|integer|min:0',
'action' => 'required|string|max:255',
'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red',
'notes' => 'nullable|string|max:500',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
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;
}
$point = MatchPoint::create([
'video_id' => $video->id,
'match_round_id' => $request->round_id,
'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action,
'points' => $request->points,
'competitor' => $request->competitor,
'notes' => $request->notes,
'score_blue' => $scoreBlue,
'score_red' => $scoreRed,
]);
return response()->json([
'success' => true,
'point' => $point,
'message' => 'Point added successfully!',
]);
}
public function updatePoint(Request $request, MatchPoint $point)
{
$request->validate([
'timestamp_seconds' => 'required|integer|min:0',
'action' => 'required|string|max:255',
'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red',
'notes' => 'nullable|string|max:500',
]);
// Check if user owns the video
if (Auth::id() !== $point->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$point->update([
'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action,
'points' => $request->points,
'competitor' => $request->competitor,
'notes' => $request->notes,
]);
return response()->json([
'success' => true,
'point' => $point,
'message' => 'Point updated successfully!',
]);
}
public function destroyPoint(MatchPoint $point)
{
// Check if user owns the video
if (Auth::id() !== $point->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$point->delete();
return response()->json([
'success' => true,
'message' => 'Point deleted successfully!',
]);
}
// ==================== COACH REVIEWS ====================
public function storeReview(Request $request, Video $video)
{
$request->validate([
'start_time_seconds' => 'required|integer|min:0',
'end_time_seconds' => 'nullable|integer|min:0',
'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review = CoachReview::create([
'video_id' => $video->id,
'user_id' => Auth::id(),
'start_time_seconds' => $request->start_time_seconds,
'end_time_seconds' => $request->end_time_seconds,
'note' => $request->note,
'coach_name' => $request->coach_name,
'emoji' => $request->emoji ?? '🔥',
]);
return response()->json([
'success' => true,
'review' => $review,
'message' => 'Coach note added successfully!',
]);
}
public function updateReview(Request $request, CoachReview $review)
{
$request->validate([
'start_time_seconds' => 'required|integer|min:0',
'end_time_seconds' => 'nullable|integer|min:0',
'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10',
]);
// Check if user owns the video
if (Auth::id() !== $review->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review->update([
'start_time_seconds' => $request->start_time_seconds,
'end_time_seconds' => $request->end_time_seconds,
'note' => $request->note,
'coach_name' => $request->coach_name,
'emoji' => $request->emoji,
]);
return response()->json([
'success' => true,
'review' => $review,
'message' => 'Coach note updated successfully!',
]);
}
public function destroyReview(CoachReview $review)
{
// Check if user owns the video
if (Auth::id() !== $review->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review->delete();
return response()->json([
'success' => true,
'message' => 'Coach note deleted successfully!',
]);
}
// ==================== GET DATA ====================
public function getMatchData(Video $video)
{
// Check if user can view this video
if (! $video->canView(Auth::user())) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$rounds = MatchRound::where('video_id', $video->id)
->with('points')
->orderBy('round_number')
->get();
$reviews = CoachReview::where('video_id', $video->id)
->orderBy('start_time_seconds')
->get();
return response()->json([
'success' => true,
'rounds' => $rounds,
'reviews' => $reviews,
]);
}
}

View File

@ -1,230 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Http\Response;
/**
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
* NAS fallback: if the file is missing locally it is fetched from the NAS and
* cached in the standard public storage directory before being served.
*
* All routes are public (no auth) so images render in emails and for guests.
*/
class MediaController extends Controller
{
public function thumbnail(string $filename, NasSyncService $nas): Response
{
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// The thumbnail field stores the local relative path.
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
// Might be a slide
if (! file_exists($local)) {
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$dir = $nas->resolveVideoDir($slide->video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
}
}
// Might be a playlist thumbnail
if (! file_exists($local)) {
$nas->ensureLocalAsset($local, $filename);
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat filename format
$local = storage_path('app/public/thumbnails/' . $filename);
if (! file_exists($local)) {
// Find the video that owns this thumbnail and pull from NAS
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$dir = $nas->resolveVideoDir($video);
$nasPath = "{$dir}/thumb.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
// Not found via video — might be a slide
if (! file_exists($local)) {
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$dir = $nas->resolveVideoDir($slide->video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
$nas->ensureLocalAsset($local, $nasPath);
}
}
if (! file_exists($local)) {
abort(404);
}
}
return $this->fileResponse($local);
}
public function avatar(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/avatar.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('avatar', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/avatars/' . $filename);
if (! file_exists($local)) {
$user = User::where('avatar', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$nasPath = "{$dir}/avatar.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
public function banner(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/cover.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('banner', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/banners/' . $filename);
if (! file_exists($local)) {
$user = User::where('banner', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$nasPath = "{$dir}/cover.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
public function postImage(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/post_images/' . $filename);
if (! file_exists($local)) abort(404);
return $this->fileResponse($local);
}
public function sportsImage(string $filename, NasSyncService $nas): Response
{
// Format: "users/{slug}/sports/{matchId}/{key}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
abort(404);
}
// ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = match ($ext) {
'webp' => 'image/webp',
'png' => 'image/png',
'gif' => 'image/gif',
default => 'image/jpeg',
};
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Cache-Control' => 'public, max-age=86400',
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
]);
}
}

View File

@ -1,647 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
}
// List user's playlists
public function index()
{
$user = Auth::user();
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
return view('playlists.index', compact('playlists'));
}
// View a single playlist
public function show(Request $request, Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
abort(404, 'Playlist not found');
}
$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(Request $request, string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$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'));
}
// Generate (or reuse) a per-user share tracking token and return the tracking URL
public function recordShare(Playlist $playlist)
{
$userId = Auth::id();
if ($userId) {
$existing = DB::table('playlist_shares')
->where('playlist_id', $playlist->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('playlists.accessShare', $existing->token)]);
}
}
do {
$token = Str::random(10);
} while (DB::table('playlist_shares')->where('token', $token)->exists());
DB::table('playlist_shares')->insert([
'playlist_id' => $playlist->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('playlists.accessShare', $token)]);
}
// Handle a share link click: record the access, then redirect to the playlist
public function accessShare(Request $request, string $token)
{
$share = DB::table('playlist_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$playlist = Playlist::find($share->playlist_id);
if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) {
return redirect('/');
}
$did = $request->cookie('_did') ?: (string) Str::uuid();
$seen = DB::table('playlist_share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
DB::table('playlist_share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'accessed_at' => now(),
]);
}
// Serve the playlist's own OG metadata to social-media crawlers so previews
// show the playlist's picture and name — not the first video's. Humans still
// get redirected to the first track for one-tap playback.
$ua = (string) $request->userAgent();
$isCrawler = (bool) preg_match(
'/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i',
$ua
);
if ($isCrawler) {
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return response()
->view('playlists.show', compact('playlist', 'videos'))
->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
: route('playlists.showByToken', $playlist->share_token);
return redirect($destination)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Create new playlist form
public function create()
{
return view('playlists.create');
}
// Store new playlist
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$playlistData = [
'user_id' => Auth::id(),
'name' => $request->name,
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
'is_default' => false,
'share_token' => Str::random(32),
];
// Create playlist first to get ID for thumbnail naming
$playlist = Playlist::create($playlistData);
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
$playlist->update(['thumbnail' => $nasPath]);
}
// Reload playlist with thumbnail
$playlist->refresh();
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'visibility' => $playlist->visibility,
'thumbnail_url' => $playlist->thumbnail_url,
],
]);
}
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist created!');
}
// Edit playlist form
public function edit(Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
if (request()->expectsJson() || request()->ajax()) {
return response()->json([
'success' => true,
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'description' => $playlist->description,
'visibility' => $playlist->visibility,
],
]);
}
return view('playlists.edit', compact('playlist'));
}
// Update playlist
public function update(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$updateData = [
'name' => $request->name,
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
];
// Handle thumbnail upload
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;
}
}
$playlist->update($updateData);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist updated!',
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'visibility' => $playlist->visibility,
],
]);
}
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist updated!');
}
// Delete playlist
public function destroy(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to delete this playlist.');
}
// Don't allow deleting default playlists
if ($playlist->is_default) {
abort(400, 'Cannot delete default playlist.');
}
$playlist->delete();
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist deleted!',
]);
}
return redirect()->route('playlists.index')->with('success', 'Playlist deleted!');
}
// Add video to playlist
public function addVideo(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'video_id' => 'required|exists:videos,id',
]);
$video = Video::findOrFail($request->video_id);
// Check if video can be viewed
if (! $video->canView(Auth::user())) {
abort(403, 'You cannot add this video to your playlist.');
}
$added = $playlist->addVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => $added ? 'Video added to playlist!' : 'Video is already in playlist.',
'video_count' => $playlist->video_count,
]);
}
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
}
/**
* Body-based mirror of removeVideo: looks up the Video by raw numeric id
* from the request body. Lets callers (e.g. the add-to-playlist modal) drive
* the toggle without having to know the encoded route key for Video.
*/
public function removeVideoByBody(Request $request, Playlist $playlist)
{
$request->validate(['video_id' => 'required|exists:videos,id']);
$video = Video::findOrFail($request->video_id);
return $this->removeVideo($request, $playlist, $video);
}
// Remove video from playlist
public function removeVideo(Request $request, Playlist $playlist, Video $video)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$removed = $playlist->removeVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Video removed from playlist.',
'video_count' => $playlist->video_count,
]);
}
return back()->with('success', 'Video removed from playlist.');
}
// Reorder videos in playlist
public function reorder(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'video_ids' => 'required|array',
'video_ids.*' => 'integer|exists:videos,id',
]);
$playlist->reorderVideos($request->video_ids);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist reordered!',
]);
}
return back()->with('success', 'Playlist reordered!');
}
// Get user's playlists (for dropdown)
public function userPlaylists()
{
// Handle unauthenticated users
if (! Auth::check()) {
return response()->json([
'success' => true,
'playlists' => [],
'authenticated' => false,
]);
}
$user = Auth::user();
$playlists = $user->playlists()->orderBy('name')->get();
// Get video IDs for each playlist
$playlistsWithVideoIds = $playlists->map(function ($p) {
return [
'id' => $p->id,
'name' => $p->name,
'description' => $p->description,
'video_count' => $p->videos()->count(),
'formatted_duration' => $p->formatted_duration,
'is_default' => $p->is_default,
'visibility' => $p->visibility,
'thumbnail_url' => $p->thumbnail_url,
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
];
});
return response()->json([
'success' => true,
'playlists' => $playlistsWithVideoIds,
'authenticated' => true,
]);
}
// Quick add to Watch Later
public function watchLater(Request $request, Video $video)
{
$watchLater = Playlist::getWatchLater(Auth::id());
$added = $watchLater->addVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => $added ? 'Added to Watch Later!' : 'Already in Watch Later.',
]);
}
return back()->with('success', $added ? 'Added to Watch Later!' : 'Already in Watch Later.');
}
// Update watch progress
public function updateProgress(Request $request, Playlist $playlist, Video $video)
{
$request->validate([
'seconds' => 'required|integer|min:0',
]);
$playlist->updateWatchProgress($video, $request->seconds);
return response()->json([
'success' => true,
]);
}
// Play all videos in playlist (redirect to first video with playlist context)
public function playAll(Playlist $playlist)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
if (! $firstVideo) {
return back()->with('error', 'Playlist is empty.');
}
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
}
// Shuffle play - redirect to random video
public function shuffle(Playlist $playlist)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$randomVideo = $playlist->getRandomVideo();
if (! $randomVideo) {
return back()->with('error', 'Playlist is empty.');
}
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
}
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
{
$nas = app(\App\Services\NasSyncService::class);
$playlist->loadMissing('user');
$userSlug = $nas->userSlug($playlist->user);
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
}
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);
return $nasPath;
}
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
{
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
try {
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
// Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG
// (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card.
public function ogImage(Playlist $playlist, NasSyncService $nas)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404);
}
if ($playlist->thumbnail) {
$local = storage_path('app/' . $playlist->thumbnail);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$nas->ensureLocalAsset($local, $playlist->thumbnail);
}
if (file_exists($local)) {
$ext = strtolower(pathinfo($local, PATHINFO_EXTENSION));
$src = match ($ext) {
'png' => @imagecreatefrompng($local),
'webp' => @imagecreatefromwebp($local),
'gif' => @imagecreatefromgif($local),
default => @imagecreatefromjpeg($local),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Always output an exact 1200x630 canvas (cover-crop, no letterbox)
// so the served image matches the og:image:width/height we declare —
// a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail.
$cw = 1200; $ch = 630;
$dst = imagecreatetruecolor($cw, $ch);
// Cover: scale so the image fills the whole canvas, center-crop overflow
$scale = max($cw / $ow, $ch / $oh);
$sw = (int) round($cw / $scale);
$sh = (int) round($ch / $scale);
$sx = (int) round(($ow - $sw) / 2);
$sy = (int) round(($oh - $sh) / 2);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Branded fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
imagefill($img, 0, 0, $cBg);
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
$tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
$title = $playlist->name ?: 'Playlist';
$maxChars = 42;
$lines = [];
if (mb_strlen($title) > $maxChars) {
$words = explode(' ', $title); $line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; }
else { $line .= ($line ? ' ' : '') . $word; }
}
if ($line) $lines[] = trim($line);
} else { $lines = [$title]; }
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
$meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
$bbox = imagettfbbox(16, 0, $fontNormal, $meta);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}

View File

@ -1,169 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use App\Notifications\NewPostNotification;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request, User $user)
{
if (Auth::id() !== $user->id) {
abort(403);
}
$request->validate([
'body' => 'nullable|string|max:2000',
'images' => 'nullable|array|max:10',
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
'video_ids' => 'nullable|array|max:10',
'video_ids.*' => 'exists:videos,id',
'video_id' => 'nullable|exists:videos,id',
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
]);
$hasImages = $request->hasFile('images');
$hasVideoIds = $request->filled('video_ids');
$hasLegacyImg = $request->hasFile('image');
$hasLegacyVid = $request->filled('video_id');
if (! $request->body && ! $hasImages && ! $hasLegacyImg && ! $hasVideoIds && ! $hasLegacyVid) {
return back()->withErrors(['body' => 'Post cannot be empty.']);
}
// Create post first — we need the ID as the folder name
$post = Post::create([
'user_id' => $user->id,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
]);
$nas = app(NasSyncService::class);
$nasMode = $nas->isEnabled();
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
if ($hasImages || $hasLegacyImg) {
if ($nasMode) {
// ── NAS primary: upload directly from PHP temp files ──────────
$nas->mkdirp($postDir);
if ($hasLegacyImg) {
$file = $request->file('image');
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/0.{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
$post->update(['image' => $nasPath]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
PostImage::create([
'post_id' => $post->id,
'filename' => $nasPath,
'sort_order' => $idx,
]);
}
}
} else {
// ── Local storage: save inside the user's posts directory ─────
$localDir = storage_path('app/' . $postDir);
@mkdir($localDir, 0755, true);
if ($hasLegacyImg) {
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
$filename = "0.{$ext}";
$request->file('image')->move($localDir, $filename);
$post->update(['image' => "{$postDir}/{$filename}"]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$filename = ($idx + 1) . ".{$ext}";
$file->move($localDir, $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => "{$postDir}/{$filename}",
'sort_order' => $idx,
]);
}
}
}
}
if ($hasVideoIds) {
foreach ($request->input('video_ids') as $idx => $videoId) {
PostVideo::create([
'post_id' => $post->id,
'video_id' => $videoId,
'sort_order' => $idx,
]);
}
}
// Notify subscribers of new post
$author = $user->fresh();
$author->subscribers()->each(function (User $subscriber) use ($post, $author) {
try { $subscriber->notify(new NewPostNotification($post, $author)); } catch (\Throwable) {}
});
return back()->with('toast_success', 'Post shared!');
}
public function destroy(Post $post)
{
if (Auth::id() !== $post->user_id && ! Auth::user()->isAdmin()) {
abort(403);
}
$post->loadMissing('postImages');
$nas = app(NasSyncService::class);
if ($nas->isEnabled()) {
try {
$nas->deleteNasPost($post);
} catch (\Throwable) {}
}
// Always clean up local copies (handles both legacy flat and new structured format)
$nas->deleteLocalPostImages($post);
$post->delete();
return back()->with('toast_success', 'Post deleted.');
}
public function react(Post $post)
{
$user = Auth::user();
$existing = $post->reactions()->where('user_id', $user->id)->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
$post->reactions()->create(['user_id' => $user->id, 'type' => 'like']);
$liked = true;
}
return response()->json([
'liked' => $liked,
'count' => $post->reactions()->count(),
]);
}
}

View File

@ -1,307 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\SportsMatch;
use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class SportsMatchController extends Controller
{
/** Single-image fields stored under the `media` JSON group. */
private const IMAGE_FIELDS = [
'media_participant1_photo' => 'participant1_photo',
'media_participant2_photo' => 'participant2_photo',
'media_referee_photo' => 'referee_photo',
'media_club1_logo' => 'club1_logo',
'media_club2_logo' => 'club2_logo',
'media_event_poster' => 'event_poster',
];
public function store(Request $request, NasSyncService $nas): JsonResponse
{
$data = $request->validate($this->rules());
$match = new SportsMatch();
$match->user_id = Auth::id();
$this->fillFromRequest($match, $request, $data);
$match->save();
$this->handleImages($match, $request, $nas);
$match->save();
return response()->json([
'ok' => true,
'message' => 'Match saved as draft.',
'match' => $this->toEditPayload($match),
]);
}
public function update(Request $request, SportsMatch $sportsMatch, NasSyncService $nas): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
$data = $request->validate($this->rules($sportsMatch));
$this->fillFromRequest($sportsMatch, $request, $data);
$this->handleImages($sportsMatch, $request, $nas);
$sportsMatch->save();
return response()->json([
'ok' => true,
'message' => 'Match updated.',
'match' => $this->toEditPayload($sportsMatch),
]);
}
/** Return the record as JSON so the modal can be re-opened for later editing. */
public function edit(SportsMatch $sportsMatch): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
return response()->json(['match' => $this->toEditPayload($sportsMatch)]);
}
// ── Validation ──────────────────────────────────────────────────────────
private function rules(?SportsMatch $existing = null): array
{
$img = ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'];
return [
// Basic — only these are required for a first (draft) save
'video_id' => ['required', 'integer', Rule::exists('videos', 'id')->where('user_id', Auth::id())],
'status' => ['nullable', Rule::in(['draft', 'published'])],
'title' => ['required', 'string', 'max:255'],
'event_name' => ['nullable', 'string', 'max:255'],
'match_date' => ['nullable', 'date'],
'match_time' => ['nullable', 'date_format:H:i'],
'participant1_name' => ['nullable', 'string', 'max:255'],
'participant2_name' => ['nullable', 'string', 'max:255'],
'referee_name' => ['nullable', 'string', 'max:255'],
// revealed later in edit
'sport' => ['nullable', 'string', 'max:80'],
'match_type' => ['nullable', 'string', 'max:80'],
'venue_name' => ['nullable', 'string', 'max:255'],
// Optional grouped scalars (free-form, kept generic)
'competition' => ['nullable', 'array'],
'participants' => ['nullable', 'array'],
'extra_participants'=> ['nullable', 'array'],
'venue' => ['nullable', 'array'],
'result' => ['nullable', 'array'],
'reviews' => ['nullable', 'array'],
'media' => ['nullable', 'array'],
// Repeatable groups
'officials' => ['nullable', 'array'],
'officials.*.role' => ['nullable', 'string', 'max:80'],
'officials.*.name' => ['nullable', 'string', 'max:255'],
'officials.*.photo' => $img,
'segments' => ['nullable', 'array'],
'segments.*.type' => ['nullable', 'string', 'max:80'],
'segments.*.number' => ['nullable', 'string', 'max:50'],
'segments.*.score' => ['nullable', 'string', 'max:255'],
'segments.*.winner' => ['nullable', 'string', 'max:255'],
'segments.*.notes' => ['nullable', 'string', 'max:2000'],
'statistics' => ['nullable', 'array'],
'statistics.*.name' => ['nullable', 'string', 'max:120'],
'statistics.*.value' => ['nullable', 'string', 'max:120'],
'statistics.*.owner' => ['nullable', 'string', 'max:255'],
'statistics.*.notes' => ['nullable', 'string', 'max:2000'],
// Single image fields
'media_participant1_photo' => $img,
'media_participant2_photo' => $img,
'media_referee_photo' => $img,
'media_club1_logo' => $img,
'media_club2_logo' => $img,
'media_event_poster' => $img,
];
}
// ── Mapping helpers ─────────────────────────────────────────────────────
private function fillFromRequest(SportsMatch $match, Request $request, array $data): void
{
$match->video_id = $data['video_id'];
$match->status = $data['status'] ?? 'draft';
$match->title = $data['title'];
$match->event_name = $data['event_name'] ?? null;
$match->match_date = $data['match_date'] ?? null;
$match->match_time = $data['match_time'] ?? null;
$match->participant1_name = $data['participant1_name'] ?? null;
$match->participant2_name = $data['participant2_name'] ?? null;
$match->referee_name = $data['referee_name'] ?? null;
$match->sport = $data['sport'] ?? null;
$match->match_type = $data['match_type'] ?? null;
$match->venue_name = $data['venue_name'] ?? null;
// Optional scalar groups — keep only non-empty values, drop the JSON if empty.
$match->competition = $this->clean($request->input('competition', []));
$match->venue = $this->clean($request->input('venue', []));
$match->result = $this->clean($request->input('result', []));
$match->reviews = $this->clean($request->input('reviews', []));
// Participants details + any extra participants in one generic structure.
$participants = $this->clean($request->input('participants', []));
$extra = collect($request->input('extra_participants', []))
->map(fn ($p) => $this->clean($p))
->filter()
->values()
->all();
if (! empty($extra)) $participants['extra'] = $extra;
$match->participants = empty($participants) ? null : $participants;
// Repeatable text groups (officials are built in handleImages() since they carry photos).
$match->segments = $this->cleanRows($request->input('segments', []));
$match->statistics = $this->cleanRows($request->input('statistics', []));
// 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', []));
if (isset($mediaText['public'])) {
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
}
$imagePaths = array_intersect_key($existingMedia, array_flip(self::IMAGE_FIELDS));
$merged = array_merge($imagePaths, $mediaText);
$match->media = empty($merged) ? null : $merged;
}
/** Strip empty values from a flat associative array; return null if nothing left. */
private function clean(?array $arr): ?array
{
if (! is_array($arr)) return null;
$out = array_filter($arr, fn ($v) => $v !== null && $v !== '' && $v !== []);
return empty($out) ? null : $out;
}
/** Clean a list of repeatable rows, dropping rows that are entirely empty. */
private function cleanRows(?array $rows): ?array
{
if (! is_array($rows)) return null;
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) continue;
// photo (file) is handled separately; drop transient hidden keys here
unset($row['photo']);
$clean = $this->clean($row);
if ($clean) $out[] = $clean;
}
return empty($out) ? null : $out;
}
// ── Image handling ──────────────────────────────────────────────────────
private function handleImages(SportsMatch $match, Request $request, NasSyncService $nas): void
{
$slug = $nas->userSlug($match->user ?: Auth::user());
$media = $match->media ?? [];
// Named single images
foreach (self::IMAGE_FIELDS as $input => $key) {
if ($request->hasFile($input)) {
$media[$key] = $this->storeImage($request->file($input), $slug, $match->id, $key, $nas);
}
}
$match->media = empty($media) ? null : $media;
// Officials — built from raw input so file inputs align by index. The
// modal renumbers rows to contiguous indices before submit. A new photo
// file replaces the existing one; otherwise the existing path is kept.
$officials = [];
foreach (array_values($request->input('officials', [])) as $i => $row) {
if (! is_array($row)) continue;
$entry = [];
if (! empty($row['role'])) $entry['role'] = $row['role'];
if (! empty($row['name'])) $entry['name'] = $row['name'];
if ($request->hasFile("officials.$i.photo")) {
$entry['photo'] = $this->storeImage(
$request->file("officials.$i.photo"), $slug, $match->id, "official-$i", $nas
);
} elseif (! empty($row['photo_existing'])) {
$entry['photo'] = $row['photo_existing'];
}
if (! empty($entry)) $officials[] = $entry;
}
$match->officials = empty($officials) ? null : $officials;
}
/**
* Write one uploaded image to the canonical NAS path
* (users/{slug}/sports/{matchId}/{key}.{ext}) and return that relative path.
*/
private function storeImage(UploadedFile $file, string $slug, int $matchId, string $key, NasSyncService $nas): string
{
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$rel = "users/{$slug}/sports/{$matchId}/{$key}.{$ext}";
$localAbs = storage_path('app/' . $rel);
@mkdir(dirname($localAbs), 0755, true);
$file->move(dirname($localAbs), basename($localAbs));
// Push to NAS and drop the local copy when the NAS is reachable.
if ($nas->isEnabled()) {
$nas->mkdirp(dirname($rel));
if ($nas->putFile($localAbs, $rel)) {
@unlink($localAbs);
}
}
return $rel;
}
/** Shape the record for the edit modal (resolves image paths to URLs). */
private function toEditPayload(SportsMatch $match): array
{
$media = $match->media ?? [];
$mediaUrls = [];
// Key the URLs by the form input name (e.g. media_participant1_photo) so the
// modal can match each preview to its field via [data-img].
foreach (self::IMAGE_FIELDS as $input => $key) {
if (! empty($media[$key])) {
$mediaUrls[$input] = route('media.sports-image', $media[$key]);
}
}
$officials = $match->officials ?? [];
foreach ($officials as &$o) {
if (! empty($o['photo'])) $o['photo_url'] = route('media.sports-image', $o['photo']);
}
unset($o);
return [
'id' => $match->id,
'video_id' => $match->video_id,
'video_title' => optional($match->video)->title,
'status' => $match->status,
'sport' => $match->sport,
'title' => $match->title,
'event_name' => $match->event_name,
'match_type' => $match->match_type,
'match_date' => optional($match->match_date)->format('Y-m-d'),
'match_time' => $match->match_time ? substr($match->match_time, 0, 5) : null,
'participant1_name' => $match->participant1_name,
'participant2_name' => $match->participant2_name,
'referee_name' => $match->referee_name,
'venue_name' => $match->venue_name,
'competition' => $match->competition,
'participants' => $match->participants,
'venue' => $match->venue,
'result' => $match->result,
'reviews' => $match->reviews,
'media' => $media,
'media_urls' => $mediaUrls,
'officials' => $officials,
'segments' => $match->segments,
'statistics' => $match->statistics,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,171 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Mail\TwoFactorDisableConfirmation;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use PragmaRX\Google2FALaravel\Support\Authenticator;
class TwoFactorController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['showChallenge', 'verifyChallenge']);
}
// POST /2fa/setup — generate secret + return QR SVG
public function setup()
{
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = $google2fa->generateSecretKey();
session(['2fa_setup_secret' => $secret]);
$qrUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer($renderer);
$qrSvg = base64_encode($writer->writeString($qrUrl));
return response()->json([
'secret' => $secret,
'qr' => $qrSvg,
]);
}
// POST /2fa/enable — confirm OTP and save secret
public function enable(Request $request)
{
$request->validate(['code' => 'required|digits:6']);
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = session('2fa_setup_secret');
if (! $secret || ! $google2fa->verifyKey($secret, $request->code)) {
return back()->with('toast_error', 'Invalid code — please try again.');
}
$user->update([
'two_factor_secret' => encrypt($secret),
'two_factor_enabled' => true,
]);
session()->forget('2fa_setup_secret');
AuditLog::record('user.2fa.enabled');
return redirect()->route('channel')->with('toast_success', 'Two-factor authentication enabled!')->with('_open_tab', 'settings');
}
// POST /2fa/disable — verifies password then sends a confirmation email
public function disable(Request $request)
{
$request->validate(['password' => 'required']);
$user = Auth::user();
if (! \Hash::check($request->password, $user->password)) {
return back()->with('toast_error', 'Incorrect password.')->with('_open_tab', 'settings');
}
$confirmUrl = URL::temporarySignedRoute(
'2fa.disable.confirm',
now()->addMinutes(15),
['user' => $user->id]
);
Mail::to($user->email)->send(new TwoFactorDisableConfirmation($user, $confirmUrl));
AuditLog::record('user.2fa.disable_requested');
return redirect()->route('channel')
->with('toast_success', 'Check your email — a confirmation link has been sent to ' . $user->email . '.')
->with('_open_tab', 'settings');
}
// GET /2fa/disable/confirm?signature=...
public function confirmDisable(Request $request)
{
if (! $request->hasValidSignature()) {
abort(403, 'This confirmation link is invalid or has expired.');
}
$user = \App\Models\User::findOrFail($request->query('user'));
// Ensure the signed URL belongs to the currently authenticated user (or log them in if
// they arrive via email while not logged in on this device)
if (Auth::check() && Auth::id() !== $user->id) {
abort(403, 'This confirmation link belongs to a different account.');
}
if (! $user->two_factor_enabled) {
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication is already disabled.')
->with('_open_tab', 'settings');
}
$user->update([
'two_factor_secret' => null,
'two_factor_enabled' => false,
]);
AuditLog::record('user.2fa.disabled', ['user_id' => $user->id, 'user_name' => $user->name]);
if (! Auth::check()) {
return redirect()->route('login')
->with('toast_success', 'Two-factor authentication has been disabled. Please log in.');
}
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication has been disabled.')
->with('_open_tab', 'settings');
}
// GET /2fa/challenge
public function showChallenge()
{
if (! session('2fa_user_id')) {
return redirect()->route('login');
}
return view('auth.2fa-challenge');
}
// POST /2fa/challenge
public function verifyChallenge(Request $request)
{
$userId = session('2fa_user_id');
if (! $userId) {
return redirect()->route('login');
}
$request->validate(['code' => 'required|digits:6']);
$user = \App\Models\User::findOrFail($userId);
$google2fa = app('pragmarx.google2fa');
$secret = decrypt($user->two_factor_secret);
if (! $google2fa->verifyKey($secret, $request->code)) {
return back()->withErrors(['code' => 'Invalid code — please try again.']);
}
session()->forget('2fa_user_id');
Auth::login($user, session()->pull('2fa_remember', false));
return redirect()->intended('/videos');
}
}

View File

@ -2,17 +2,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\Horoscope;
use App\Models\AuditLog;
use App\Models\Post;
use App\Models\User; use App\Models\User;
use App\Models\Video; use App\Models\Video;
use App\Notifications\NewSubscriberNotification;
use App\Notifications\VideoLikedNotification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller class UserController extends Controller
{ {
@ -21,140 +17,45 @@ class UserController extends Controller
$this->middleware('auth')->except(['channel']); $this->middleware('auth')->except(['channel']);
} }
// Typeahead search for members (used by the "Share to member" picker) // Profile page - view own profile
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 1) {
return response()->json(['users' => []]);
}
$users = User::where('id', '!=', Auth::id())
->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('username', 'like', "%{$q}%");
})
->orderBy('name')
->limit(8)
->get(['id', 'name', 'username', 'avatar']);
return response()->json([
'users' => $users->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'channel' => $u->channel,
'avatar' => $u->avatar_url,
]),
]);
}
// Profile page - personal overview for the authenticated user
public function profile() public function profile()
{ {
$user = Auth::user(); $user = Auth::user();
return view('user.profile', compact('user')); return view('user.profile', compact('user'));
} }
// Update profile // Update profile
public function updateProfile(Request $request) public function updateProfile(Request $request)
{ {
$authUser = Auth::user(); $user = Auth::user();
// Super admins may edit any user's profile by passing _edit_user_id
if ($authUser->isSuperAdmin() && $request->filled('_edit_user_id')) {
$user = User::findOrFail($request->input('_edit_user_id'));
} else {
$user = $authUser;
}
$request->validate([ $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
'bio' => 'nullable|string|max:500',
'birthday' => 'nullable|date',
'location' => 'nullable|string|max:100',
'gender' => 'nullable|in:male,female,prefer_not_to_say',
'nationality' => 'nullable|string|size:2',
'phone_code' => 'nullable|string|max:20',
'phone_number' => 'nullable|string|max:30',
'timezone' => 'nullable|timezone:all',
'slink' => 'nullable|array',
'slink.*.platform' => 'required_with:slink|string|max:30',
'slink.*.value' => 'required_with:slink|string|max:500',
'slink.*.visibility' => 'nullable|in:public,registered,subscribers,only_me',
]); ]);
$data = [ $data = ['name' => $request->name];
'name' => $request->name,
'bio' => $request->bio,
'birthday' => $request->birthday,
'location' => $request->location,
'gender' => $request->gender ?: null,
'nationality' => $request->nationality ?: null,
'phone_code' => $request->phone_code ?: null,
'phone_number' => $request->phone_number ?: null,
'timezone' => $request->timezone ?: null,
];
$nas = app(\App\Services\NasSyncService::class);
if ($request->hasFile('avatar')) { if ($request->hasFile('avatar')) {
// Delete old avatar (handles both flat and new relative-path formats) // Delete old avatar
$nas->deleteLocalAvatar($user); if ($user->avatar) {
Storage::delete('public/avatars/' . $user->avatar);
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp'; }
$profileDir = $nas->localProfileDir($user); $filename = Str::uuid() . '.' . $request->file('avatar')->getClientOriginalExtension();
$destFilename = "avatar.{$ext}"; $request->file('avatar')->storeAs('public/avatars', $filename);
$destPath = "{$profileDir}/{$destFilename}"; $data['avatar'] = $filename;
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
@mkdir($profileDir, 0755, true);
$request->file('avatar')->move($profileDir, $destFilename);
$data['avatar'] = $relPath;
} }
$user->update($data); $user->update($data);
// Push avatar to NAS and remove local copy when NAS is primary storage return redirect()->route('profile')->with('success', 'Profile updated successfully!');
if ($nas->isEnabled()) {
if ($request->hasFile('avatar')) {
$destPath = storage_path('app/' . $data['avatar']);
if (file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
}
}
// Sync social links
$user->socialLinks()->delete();
$order = 0;
foreach ($request->input('slink', []) as $entry) {
$platform = trim($entry['platform'] ?? '');
$value = trim($entry['value'] ?? '');
if ($platform && $value) {
$user->socialLinks()->create([
'platform' => $platform,
'value' => $value,
'visibility' => $entry['visibility'] ?? 'public',
'sort_order' => $order++,
]);
}
}
// Redirect back to profile page, or channel settings if admin edited another user
if ($authUser->isSuperAdmin() && $authUser->id !== $user->id) {
return redirect()->route('channel', $user->channel)->with('toast_success', 'Profile updated!')->with('_open_tab', 'settings');
}
return redirect()->route('profile')->with('toast_success', 'Profile updated!');
} }
// Settings page - redirects to channel settings tab // Settings page
public function settings() public function settings()
{ {
return redirect()->route('channel')->with('_open_tab', 'settings'); $user = Auth::user();
return view('user.settings', compact('user'));
} }
// Update settings (password) // Update settings (password)
@ -167,146 +68,40 @@ class UserController extends Controller
'new_password' => 'required|min:8|confirmed', 'new_password' => 'required|min:8|confirmed',
]); ]);
if (! Hash::check($request->current_password, $user->password)) { if (!Hash::check($request->current_password, $user->password)) {
return back()->withErrors(['current_password' => 'Current password is incorrect']); return back()->withErrors(['current_password' => 'Current password is incorrect']);
} }
$user->update([ $user->update([
'password' => Hash::make($request->new_password), 'password' => Hash::make($request->new_password)
]); ]);
AuditLog::record('user.password_changed'); return redirect()->route('settings')->with('success', 'Password updated successfully!');
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
}
// Save a single notification preference toggle (AJAX)
public function updateNotificationPreferences(Request $request)
{
$request->validate([
'key' => ['required', 'string', 'in:' . implode(',', array_keys(User::notifDefaults()))],
'value' => ['required', 'boolean'],
]);
$user = Auth::user();
$prefs = $user->notification_preferences ?? [];
$prefs[$request->key] = (bool) $request->value;
$user->update(['notification_preferences' => $prefs]);
return response()->json(['ok' => true]);
}
// Logout all other devices
public function logoutAllDevices(Request $request)
{
$request->validate(['password' => 'required']);
if (! Hash::check($request->password, Auth::user()->password)) {
return back()->withErrors(['logout_password' => 'Incorrect password.'])->with('_open_tab', 'settings');
}
Auth::logoutOtherDevices($request->password);
// AuthenticateSession stores a hash of the password; logoutOtherDevices rehashes it,
// so we must update the session's copy or the current session gets invalidated too.
$request->session()->put(
'password_hash_' . Auth::getDefaultDriver(),
Auth::user()->getAuthPassword()
);
AuditLog::record('user.logout_all');
return redirect()->route('channel')->with('toast_success', 'All other sessions have been logged out.')->with('_open_tab', 'settings');
} }
// User's channel page - view videos // User's channel page - view videos
public function channel($username = null) public function channel($userId = null)
{ {
if ($username) { if ($userId) {
// Look up by username slug only — never by sequential ID $user = User::findOrFail($userId);
$user = User::where('username', $username)->firstOrFail();
} else { } else {
$user = Auth::user(); $user = Auth::user();
if (! $user) {
return redirect()->route('login');
}
$user->channel; // triggers auto-generation if missing
} }
$sort = request('sort', 'latest'); // If viewing own channel, show all videos including private
$isOwner = Auth::check() && Auth::user()->id === $user->id; // If viewing someone else's channel, show only public videos
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor if (Auth::check() && Auth::user()->id === $user->id) {
$isOwner = $isOwner && !$preview; $videos = Video::where('user_id', $user->id)
->latest()
$baseQuery = $isOwner ->paginate(12);
? Video::where('user_id', $user->id) } else {
: Video::public()->where('user_id', $user->id); $videos = Video::public()
->where('user_id', $user->id)
$allQuery = clone $baseQuery; ->latest()
->paginate(12);
switch ($sort) {
case 'popular':
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
break;
case 'oldest':
$baseQuery->oldest();
break;
default:
$baseQuery->latest();
} }
$videos = $baseQuery->where('is_shorts', false)->get(); return view('user.channel', compact('user', 'videos'));
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
$withFirstVideo = fn($q) => $q->orderBy('playlist_videos.position')->limit(1);
$playlists = $isOwner
? $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();
$totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id'))
->count();
// Filter social links by visibility for the current viewer
$viewer = Auth::user();
$isOwner = $viewer && $viewer->id === $user->id;
$isSubscriber = $viewer && !$isOwner && $viewer->isSubscribedTo($user);
$socialLinks = $user->socialLinks()
->orderBy('sort_order')
->get()
->filter(function ($link) use ($isOwner, $viewer, $isSubscriber) {
if ($isOwner) return true;
return match ($link->visibility) {
'public' => true,
'registered' => (bool) $viewer,
'subscribers' => $viewer && $isSubscriber,
'only_me' => false,
default => false,
};
});
// Posts for the Wall tab
$posts = Post::where('user_id', $user->id)
->with(['user', 'video', 'reactions', 'postImages', 'postVideos.video'])
->latest()
->get();
// Horoscope
$horoscope = Horoscope::getSign($user->birthday);
$viewerSign = null;
$compatibility = null;
if ($viewer && ! $isOwner && $viewer->birthday) {
$viewerSign = Horoscope::getSign($viewer->birthday);
$compatibility = Horoscope::compatibility($horoscope, $viewerSign);
}
$canEdit = $isOwner || (Auth::check() && Auth::user()->isSuperAdmin());
return view('user.channel', compact(
'user', 'videos', 'shorts', 'playlists', 'totalViews', 'sort',
'socialLinks', 'isSubscriber', 'isOwner', 'canEdit', 'posts', 'horoscope', 'viewerSign', 'compatibility',
'preview'
));
} }
// Watch history // Watch history
@ -323,37 +118,27 @@ class UserController extends Controller
->unique(); ->unique();
$videos = Video::whereIn('id', $videoIds) $videos = Video::whereIn('id', $videoIds)
->where(function ($q) use ($user) { ->where(function($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id); ->orWhere('user_id', $user->id);
}) })
->get() ->get()
->sortBy(function ($video) use ($videoIds) { ->sortByDesc(function ($video) use ($videoIds) {
return $videoIds->search($video->id); return $videoIds->search($video->id);
}); });
return view('user.history', compact('videos')); return view('user.history', compact('videos'));
} }
// Clear watch history
public function clearHistory()
{
\DB::table('video_views')
->where('user_id', Auth::id())
->delete();
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
}
// Liked videos // Liked videos
public function liked() public function liked()
{ {
$user = Auth::user(); $user = Auth::user();
// Include private videos in liked (user's own private videos) // Include private videos in liked (user's own private videos)
$videos = $user->likes() $videos = $user->likes()
->where(function ($q) use ($user) { ->where(function($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id); ->orWhere('videos.user_id', $user->id);
}) })
->latest() ->latest()
->paginate(12); ->paginate(12);
@ -366,7 +151,7 @@ class UserController extends Controller
{ {
$user = Auth::user(); $user = Auth::user();
if (! $video->isLikedBy($user)) { if (!$video->isLikedBy($user)) {
$video->likes()->attach($user->id); $video->likes()->attach($user->id);
} }
@ -394,218 +179,12 @@ class UserController extends Controller
} else { } else {
$video->likes()->attach($user->id); $video->likes()->attach($user->id);
$liked = true; $liked = true;
if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
}
} }
return response()->json([ return response()->json([
'liked' => $liked, 'liked' => $liked,
'like_count' => $video->like_count, 'like_count' => $video->like_count
]); ]);
} }
public function recordProfileVisit(Request $request, User $user)
{
// Don't record self-visits or repeated visits from the same person in the last 30 minutes
$visitorId = Auth::id();
$deviceId = $request->cookie('_did');
if ($visitorId && $visitorId === $user->id) {
return response()->json(['ok' => true, 'skipped' => 'self']);
}
$sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$dedup = \App\Models\ProfileVisit::where('profile_user_id', $user->id)
->where('created_at', '>=', now()->subMinutes(30))
->when($visitorId, fn ($q) => $q->where('visitor_user_id', $visitorId))
->when(! $visitorId && $deviceId, fn ($q) => $q->whereNull('visitor_user_id')->where('device_id', $deviceId))
->when($sourceVideoId, fn ($q) => $q->where('source_video_id', $sourceVideoId))
->exists();
if ($dedup) {
return response()->json(['ok' => true, 'skipped' => 'dedup']);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\App\Models\ProfileVisit::create([
'profile_user_id' => $user->id,
'visitor_user_id' => $visitorId,
'device_id' => $deviceId,
'source_video_id' => $sourceVideoId,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
public function toggleSubscribe(Request $request, User $user)
{
$me = Auth::user();
if ($me->id === $user->id) {
return response()->json(['error' => 'Cannot subscribe to yourself'], 422);
}
if ($me->isSubscribedTo($user)) {
$me->subscriptions()->detach($user->id);
$subscribed = false;
} else {
$sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! \App\Models\Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$me->subscriptions()->attach($user->id, ['source_video_id' => $sourceVideoId]);
$subscribed = true;
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
}
return response()->json([
'subscribed' => $subscribed,
'subscriber_count' => $user->fresh()->subscriber_count,
]);
}
public function notificationCount()
{
return response()->json(['unread_count' => Auth::user()->unreadNotifications()->count()]);
}
public function fetchNotifications()
{
$user = Auth::user();
$rawNotifications = $user->notifications()->latest()->take(50)->get();
// Bulk-fetch video state only for video-linked notifications
$videoIds = $rawNotifications
->pluck('data.video_id')
->filter()
->unique()
->values();
$videos = $videoIds->isNotEmpty()
? \App\Models\Video::whereIn('id', $videoIds)
->whereIn('visibility', ['public', 'unlisted'])
->get(['id', 'thumbnail', 'visibility'])
->keyBy('id')
: collect();
$notifications = $rawNotifications
->filter(function ($n) use ($videos) {
$videoId = $n->data['video_id'] ?? null;
// Non-video notifications (subscriber, like, post, new_user) always pass
if (!$videoId) return true;
// Video notifications only if the video is still visible
return $videos->has($videoId);
})
->take(30)
->map(function ($n) use ($videos) {
$data = $n->data;
if (!empty($data['video_id'])) {
$data['video_thumbnail'] = $videos->get($data['video_id'])?->thumbnail ?? null;
}
return [
'id' => $n->id,
'read' => ! is_null($n->read_at),
'time' => $n->created_at->diffForHumans(),
'data' => $data,
];
})
->values();
return response()->json([
'notifications' => $notifications,
'unread_count' => $user->unreadNotifications()->count(),
]);
}
public function markNotificationRead(string $id)
{
$notification = Auth::user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['ok' => true]);
}
public function markAllNotificationsRead()
{
Auth::user()->unreadNotifications->markAsRead();
return response()->json(['ok' => true]);
}
public function updateAvatar(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/avatars/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old avatar before moving new one in (handles both path formats)
$nas->deleteLocalAvatar($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['avatar' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
return response()->json(['ok' => true]);
}
public function updateBanner(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/banners/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "cover.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old banner before moving new one in (handles both path formats)
$nas->deleteLocalBanner($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['banner' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncCover($user, $destPath);
$nas->deleteLocalBanner($user);
}
return response()->json(['ok' => true]);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,6 @@ class Kernel extends HttpKernel
\App\Http\Middleware\EncryptCookies::class, \App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,

View File

@ -12,7 +12,7 @@ class TrustProxies extends Middleware
* *
* @var array<int, string>|string|null * @var array<int, string>|string|null
*/ */
protected $proxies = '*'; protected $proxies;
/** /**
* The headers that should be used to detect proxies. * The headers that should be used to detect proxies.

View File

@ -12,8 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string> * @var array<int, string>
*/ */
protected $except = [ protected $except = [
'videos/*/share', //
'playlists/*/share',
'videos/*/slideshow/generate',
]; ];
} }

View File

@ -2,11 +2,9 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video; use App\Models\Video;
use FFMpeg\FFMpeg; use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\X264; use FFMpeg\Format\Video\X264;
use Illuminate\Support\Facades\Config;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -38,54 +36,21 @@ class CompressVideoJob implements ShouldQueue
return; return;
} }
// Create compressed file alongside the original // Create compressed filename
$compressedFilename = 'compressed_' . $video->filename; $compressedFilename = 'compressed_' . $video->filename;
$compressedPath = dirname($originalPath) . '/' . $compressedFilename; $compressedPath = storage_path('app/public/videos/' . $compressedFilename);
try { try {
$ffmpegConfig = Config::get('ffmpeg'); $ffmpeg = FFMpeg::create();
$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); $ffmpegVideo = $ffmpeg->open($originalPath);
// Verify the GPU is actually reachable and able to encode before sending // Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
// the file to it; otherwise fall back to CPU so the job never hangs. // Use 'slow' preset for better compression efficiency
$gpuEnabled = Setting::gpuUsable(); $format = new X264('aac', 'libx264');
$encoder = Setting::gpuEncoder(); $format->setKiloBitrate(0); // 0 = use CRF
$preset = Setting::gpuPreset(); $format->setAudioKiloBitrate(192);
$device = Setting::gpuDevice();
if ($gpuEnabled) { // Add CRF option for high quality
$videoPasses = [
"-c:v {$encoder}",
"-preset {$preset}",
'-rc vbr',
'-cq 23',
'-profile:v high',
'-pix_fmt yuv420p',
"-gpu {$device}",
];
} else {
$videoPasses = [
'-c:v libx264',
'-preset fast',
'-crf 23',
'-profile:v high',
'-pix_fmt yuv420p',
];
}
$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); $ffmpegVideo->save($format, $compressedPath);
// Check if compressed file was created and is smaller // Check if compressed file was created and is smaller
@ -106,13 +71,11 @@ class CompressVideoJob implements ShouldQueue
'mime_type' => 'video/mp4', 'mime_type' => 'video/mp4',
]); ]);
Log::info('CompressVideoJob: Video compressed', [ Log::info('CompressVideoJob: Video compressed successfully', [
'video_id' => $video->id, 'video_id' => $video->id,
'original_size' => $originalSize, 'original_size' => $originalSize,
'compressed_size' => $compressedSize, 'compressed_size' => $compressedSize,
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%', 'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%'
'encoder' => $encoder,
'gpu' => $gpuEnabled,
]); ]);
} else { } else {
// Compressed file is larger, delete it // Compressed file is larger, delete it
@ -123,9 +86,6 @@ class CompressVideoJob implements ShouldQueue
$video->update(['status' => 'ready']); $video->update(['status' => 'ready']);
// Chain to HLS generation for GPU-accelerated adaptive playback
\App\Jobs\GenerateHlsJob::dispatch($video);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('CompressVideoJob failed: ' . $e->getMessage()); Log::error('CompressVideoJob failed: ' . $e->getMessage());
$video->update(['status' => 'ready']); // Mark as ready anyway $video->update(['status' => 'ready']); // Mark as ready anyway

View File

@ -1,139 +0,0 @@
<?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;
}
}

View File

@ -1,192 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
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\Storage;
class GenerateHlsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $video;
public function __construct(Video $video)
{
$this->video = $video;
$this->onQueue('video-processing');
}
public function handle()
{
$video = $this->video->fresh();
if ($video->status !== 'ready') {
Log::warning('GenerateHlsJob: Video not ready', ['id' => $video->id]);
return;
}
$sourcePath = storage_path('app/' . $video->path);
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
if (! file_exists($sourcePath)) {
// NAS-primary mode: file lives on NAS, download a temporary local copy
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$localCopy = $nas->ensureLocalCopy($video);
if ($localCopy) {
$sourcePath = $localCopy;
$nasDownloaded = $localCopy;
}
}
if (! file_exists($sourcePath)) {
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
return;
}
}
// HLS rendition lives in the song's own cache/ subfolder (regenerable, local-only —
// never pushed to NAS). Fall back to the shared public/hls only for legacy rows
// whose path is not in the users/ layout.
$hlsDir = str_starts_with((string) $video->path, 'users/')
? dirname($video->path) . '/cache/hls'
: 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
if (is_dir($hlsPath)) {
Storage::deleteDirectory($hlsDir);
}
Storage::makeDirectory($hlsDir);
$variants = [
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
];
foreach ($variants as $v) {
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
}
try {
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
// 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(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
$hwaccel = Setting::gpuHwaccel(); // cuda / none
$cmd = [escapeshellcmd($ffmpegBin)];
// Hardware-accelerated decode when GPU is in use
if ($gpuEnabled && $hwaccel !== 'none') {
$cmd[] = "-hwaccel {$hwaccel}";
$cmd[] = "-hwaccel_device {$device}";
}
$cmd[] = '-i ' . escapeshellarg($sourcePath);
// One video+audio stream per variant
$n = count($variants);
for ($i = 0; $i < $n; $i++) {
$cmd[] = '-map 0:v:0';
$cmd[] = '-map 0:a:0?';
}
// Video codec
$cmd[] = "-c:v {$encoder}";
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
$cmd[] = "-preset {$preset}";
$cmd[] = '-rc vbr';
$cmd[] = '-cq 23';
$cmd[] = "-gpu {$device}";
} else {
$cmd[] = "-preset {$preset}";
$cmd[] = '-crf 23';
}
$cmd[] = '-pix_fmt yuv420p';
// Audio codec
$cmd[] = '-c:a aac';
$cmd[] = '-b:a 128k';
$cmd[] = '-ar 48000';
// Per-variant scale + bitrate
for ($i = 0; $i < $n; $i++) {
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
}
// HLS muxer options
$cmd[] = '-g 48';
$cmd[] = '-sc_threshold 0';
$cmd[] = '-f hls';
$cmd[] = '-hls_time 6';
$cmd[] = '-hls_list_size 0';
$cmd[] = '-hls_flags independent_segments';
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
$cmd[] = '-master_pl_name playlist.m3u8';
$vsm = implode(' ', array_map(
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
array_keys($variants),
$variants
));
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
$fullCmd = implode(' ', $cmd) . ' 2>&1';
Log::info('GenerateHlsJob: Starting', [
'video_id' => $video->id,
'gpu' => $gpuEnabled,
'encoder' => $encoder,
'device' => $gpuEnabled ? $device : 'cpu',
]);
exec($fullCmd, $output, $exitCode);
if ($exitCode !== 0) {
$tail = implode("\n", array_slice($output, -30));
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
}
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
Log::info('GenerateHlsJob: HLS generated', [
'video_id' => $video->id,
'variants' => array_column($variants, 'name'),
'encoder' => $encoder,
]);
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
if ($nasDownloaded) {
// NAS-primary mode: video was fetched from NAS for HLS generation.
// The original is already on NAS — just delete the local temp copy.
@unlink($nasDownloaded);
} else {
// Local-storage mode: push the (compressed) file to NAS and free local disk.
// HLS segments stay local — per-segment SMB latency would hurt playback.
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
$nas->deleteLocalAssets($video);
}
}
} catch (\Exception $e) {
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
Storage::deleteDirectory($hlsDir);
}
}
}

View File

@ -1,244 +0,0 @@
<?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);
}
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Video;
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;
class NasSyncVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly Video $video) {}
public function handle(NasSyncService $nas): void
{
if (! $nas->isEnabled()) return;
try {
$nas->syncVideo($this->video);
// Audio/music uploads have no further processing jobs, so it's safe
// to remove the local file immediately after a successful NAS push.
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
}
$nas->deleteLocalAssets($this->video);
$nas->pruneLocalVideoDir($this->video);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error(
'NasSyncVideoJob failed for video #' . $this->video->id .
' ("' . $this->video->title . '"): ' . $e->getMessage() .
' — local files kept. Run `php artisan nas:repair --force` to retry.'
);
}
}
}

View File

@ -1,184 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
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\Cache;
use Illuminate\Support\Facades\Log;
class NasToLocalMigrationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 7200; // 2 hours
private function updateProgress(array $data): void
{
Cache::put('nas_disable_progress', json_encode($data), 3600);
}
public function handle(NasSyncService $nas): void
{
$progress = [
'current' => 0,
'total' => 0,
'phase' => 'Counting files...',
'done' => false,
'error' => null,
];
$this->updateProgress($progress);
try {
// ── Count all items ───────────────────────────────────────────────
$videos = Video::where('path', 'like', 'users/%')->get();
$slides = \DB::table('video_slides')
->where('filename', 'like', 'users/%')
->get();
$usersWithAvatar = User::where('avatar', 'like', 'users/%')->get();
$usersWithBanner = User::where('banner', 'like', 'users/%')->get();
$postImages = \DB::table('post_images')
->where('filename', 'like', 'users/%')
->get();
// Count thumbnails separately (they're additional per-video downloads)
$videoThumbs = $videos->filter(fn($v) => $v->thumbnail && str_starts_with($v->thumbnail, 'users/'));
$total = $videos->count()
+ $videoThumbs->count()
+ $slides->count()
+ $usersWithAvatar->count()
+ $usersWithBanner->count()
+ $postImages->count();
$progress['total'] = $total;
$progress['phase'] = 'Downloading files from NAS...';
$this->updateProgress($progress);
// ── Download videos ───────────────────────────────────────────────
foreach ($videos as $video) {
$nasPath = $video->path;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading videos... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download thumbnails ───────────────────────────────────────────
foreach ($videoThumbs as $video) {
$nasPath = $video->thumbnail;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download thumbnail for video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading thumbnails... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download slides ───────────────────────────────────────────────
foreach ($slides as $slide) {
$nasPath = $slide->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download slide id=' . $slide->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading audio slides... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user avatars ─────────────────────────────────────────
foreach ($usersWithAvatar as $user) {
$nasPath = $user->avatar;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download avatar for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading avatars... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user banners ─────────────────────────────────────────
foreach ($usersWithBanner as $user) {
$nasPath = $user->banner;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download banner for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading banners... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download post images ──────────────────────────────────────────
foreach ($postImages as $img) {
$nasPath = $img->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download post image id=' . $img->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading post images... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Disable NAS ───────────────────────────────────────────────────
Setting::set('nas_sync_enabled', 'false');
app(\App\Services\NasSyncService::class)->flushReachabilityCache();
$progress['done'] = true;
$progress['phase'] = 'Complete';
$this->updateProgress($progress);
Log::info('NasToLocalMigrationJob: completed. ' . $total . ' files migrated. NAS disabled.');
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: fatal error: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$progress['error'] = $e->getMessage();
$this->updateProgress($progress);
}
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Mail;
use App\Models\Video;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewVideoNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Video $video, public User $uploader)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->uploader->name . ' just uploaded a new video on ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.new-video-notification',
);
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TwoFactorDisableConfirmation extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user, public string $confirmUrl)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Confirm disabling Two-Factor Authentication — ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.2fa-disable-confirm',
);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Models\Video;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VideoShared extends Mailable
{
use Queueable, SerializesModels;
public string $shareTitle;
public function __construct(
public Video $video,
public string $shareUrl,
public User $sender,
public ?string $personalMessage = null,
?string $shareTitle = null,
) {
// Version-aware title (e.g. the English track's title) with a primary fallback.
$this->shareTitle = $shareTitle ?: $video->title;
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->sender->name . ' shared "' . $this->shareTitle . '" with you on ' . config('app.name'),
// Let the recipient reply straight to the friend who sent it.
replyTo: $this->sender->email
? [new Address($this->sender->email, $this->sender->name)]
: [],
);
}
public function content(): Content
{
return new Content(view: 'emails.video-shared');
}
}

View File

@ -1,86 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id', 'user_name', 'action', 'subject_type', 'subject_id',
'subject_label', 'ip_address', 'user_agent', 'details', 'created_at',
];
protected $casts = [
'details' => 'array',
'created_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public static function record(string $action, array $options = []): void
{
$request = request();
$user = auth()->user();
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Forwarded-For')
?? $request->ip();
static::create([
'user_id' => $options['user_id'] ?? $user?->id,
'user_name' => $options['user_name'] ?? $user?->name,
'action' => $action,
'subject_type' => $options['subject_type'] ?? null,
'subject_id' => $options['subject_id'] ?? null,
'subject_label' => $options['subject_label'] ?? null,
'ip_address' => $ip,
'user_agent' => substr($request->userAgent() ?? '', 0, 255),
'details' => $options['details'] ?? null,
'created_at' => now(),
]);
}
// Severity grouping for UI color-coding
public function getSeverityAttribute(): string
{
return match(true) {
str_contains($this->action, 'delete') || str_contains($this->action, 'destroy') => 'danger',
str_contains($this->action, 'login.failed') => 'warning',
str_contains($this->action, 'login') => 'info',
str_contains($this->action, 'logout') => 'muted',
str_contains($this->action, 'admin.') => 'orange',
str_contains($this->action, 'upload') || str_contains($this->action, 'create') => 'success',
str_contains($this->action, '2fa') || str_contains($this->action, 'password') => 'purple',
default => 'default',
};
}
public function getActionLabelAttribute(): string
{
return match($this->action) {
'video.uploaded' => 'Video Uploaded',
'video.deleted' => 'Video Deleted',
'video.updated' => 'Video Updated',
'user.login' => 'Logged In',
'user.login.failed' => 'Login Failed',
'user.logout' => 'Logged Out',
'user.logout_all' => 'Logged Out All Devices',
'user.password_changed' => 'Password Changed',
'user.2fa.enabled' => '2FA Enabled',
'user.2fa.disabled' => '2FA Disabled',
'admin.impersonate' => 'Impersonated User',
'admin.impersonate.exit' => 'Exited Impersonation',
'admin.user.deleted' => 'Admin: User Deleted',
'admin.video.deleted' => 'Admin: Video Deleted',
'admin.user.updated' => 'Admin: User Updated',
'admin.video.updated' => 'Admin: Video Updated',
default => ucwords(str_replace(['.', '_'], ' ', $this->action)),
};
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CoachReview extends Model
{
use HasFactory;
protected $fillable = [
'video_id',
'user_id',
'start_time_seconds',
'end_time_seconds',
'note',
'coach_name',
'emoji',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -34,11 +34,6 @@ class Comment extends Model
return $this->hasMany(Comment::class, 'parent_id')->latest(); return $this->hasMany(Comment::class, 'parent_id')->latest();
} }
public function likes()
{
return $this->hasMany(CommentLike::class);
}
// Get mentioned users from comment body // Get mentioned users from comment body
public function getMentionedUsers() public function getMentionedUsers()
{ {

View File

@ -1,22 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CommentLike extends Model
{
public $timestamps = false;
protected $fillable = ['comment_id', 'user_id'];
public function comment()
{
return $this->belongsTo(Comment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MatchPoint extends Model
{
use HasFactory;
protected $fillable = [
'video_id',
'match_round_id',
'timestamp_seconds',
'action',
'points',
'competitor',
'notes',
'score_blue',
'score_red',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function round(): BelongsTo
{
return $this->belongsTo(MatchRound::class, 'match_round_id');
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MatchRound extends Model
{
use HasFactory;
protected $fillable = [
'video_id',
'round_number',
'name',
'start_time_seconds',
];
protected $casts = [
'start_time_seconds' => 'integer',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function points(): HasMany
{
return $this->hasMany(MatchPoint::class, 'match_round_id')->orderBy('timestamp_seconds');
}
}

View File

@ -1,413 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Playlist extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'description',
'thumbnail',
'visibility',
'is_default',
'share_token',
'view_count',
];
protected $casts = [
'is_default' => 'boolean',
'view_count' => 'integer',
];
// Relationships
public function user()
{
return $this->belongsTo(User::class);
}
public function videos()
{
return $this->belongsToMany(Video::class, 'playlist_videos')
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
->orderBy('position');
}
// Get videos with their pivot data
public function getVideosWithPivot()
{
return $this->videos()->orderBy('position')->get();
}
// Accessors
public function getThumbnailUrlAttribute(): string
{
if ($this->thumbnail) {
return route('media.thumbnail', $this->thumbnail);
}
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
}
// Get total video count
public function getVideoCountAttribute()
{
return $this->videos()->count();
}
// Get total duration of all videos in playlist
public function getTotalDurationAttribute()
{
return $this->videos()->sum('duration');
}
// Get formatted total duration (e.g., "2h 30m")
public function getFormattedDurationAttribute()
{
$totalSeconds = $this->total_duration;
if (! $totalSeconds) {
return '0m';
}
$hours = floor($totalSeconds / 3600);
$minutes = floor(($totalSeconds % 3600) / 60);
if ($hours > 0) {
return "{$hours}h {$minutes}m";
}
return "{$minutes}m";
}
// 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)
{
if (! $user) {
return false;
}
return $this->user_id === $user->id;
}
// Check if video is in this playlist
public function hasVideo(Video $video)
{
return $this->videos()->where('video_id', $video->id)->exists();
}
// Get video position in playlist
public function getVideoPosition(Video $video)
{
$pivot = $this->videos()->where('video_id', $video->id)->first();
return $pivot ? $pivot->pivot->position : null;
}
// Get next video in playlist
public function getNextVideo(Video $currentVideo)
{
$currentPosition = $this->getVideoPosition($currentVideo);
if ($currentPosition === null) {
return $this->videos()->first();
}
return $this->videos()
->wherePivot('position', '>', $currentPosition)
->orderBy('position')
->first();
}
// Get previous video in playlist
public function getPreviousVideo(Video $currentVideo)
{
$currentPosition = $this->getVideoPosition($currentVideo);
if ($currentPosition === null) {
return $this->videos()->last();
}
return $this->videos()
->wherePivot('position', '<', $currentPosition)
->orderByDesc('position')
->first();
}
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('playlists.showByToken', $this->share_token);
}
// Scope for public playlists
public function scopePublic($query)
{
return $query->where('visibility', 'public');
}
// Scope for user's playlists
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
// Scope for default playlists (Watch Later)
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
// Visibility helpers
public function isPublic()
{
return $this->visibility === 'public';
}
public function isPrivate()
{
return $this->visibility === 'private';
}
public function isUnlisted()
{
return $this->visibility === 'unlisted';
}
// Check if user can view this playlist via the ID route
public function canView($user = null)
{
// Owner can always view
if ($user && $this->user_id === $user->id) {
return true;
}
// Only public playlists are accessible via the ID route
return $this->visibility === 'public';
}
// Check if user can view this playlist via the share-token route
public function canViewViaToken($user = null)
{
if ($user && $this->user_id === $user->id) {
return true;
}
return in_array($this->visibility, ['public', 'unlisted']);
}
// Check if user can edit this playlist
public function canEdit($user = null)
{
return $user && $this->user_id === $user->id;
}
// Update video positions after reordering
public function reorderVideos($videoIds)
{
foreach ($videoIds as $index => $videoId) {
$this->videos()->updateExistingPivot($videoId, ['position' => $index]);
}
}
// Add video to playlist
public function addVideo(Video $video)
{
if ($this->hasVideo($video)) {
return false;
}
$maxPosition = $this->videos()->max('position') ?? -1;
$this->videos()->attach($video->id, [
'position' => $maxPosition + 1,
'added_at' => now(),
]);
return true;
}
// Remove video from playlist
public function removeVideo(Video $video)
{
if (! $this->hasVideo($video)) {
return false;
}
$this->videos()->detach($video->id);
// Reorder remaining videos
$this->reorderPositions();
return true;
}
// Reorder positions after removal
protected function reorderPositions()
{
$videos = $this->videos()->orderBy('position')->get();
$position = 0;
foreach ($videos as $video) {
$this->videos()->updateExistingPivot($video->id, ['position' => $position]);
$position++;
}
}
// Update watch progress for a video in playlist
public function updateWatchProgress(Video $video, $seconds)
{
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
if ($pivot) {
$pivot->watched_seconds = $seconds;
$pivot->last_watched_at = now();
// Mark as watched if 90% complete
if ($video->duration && $seconds >= ($video->duration * 0.9)) {
$pivot->watched = true;
}
$pivot->save();
}
}
// Get watch progress for a video in playlist
public function getWatchProgress(Video $video)
{
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
return $pivot ? [
'watched_seconds' => $pivot->watched_seconds,
'watched' => $pivot->watched,
'last_watched_at' => $pivot->last_watched_at,
] : null;
}
// Get first unwatched video
public function getFirstUnwatchedVideo()
{
return $this->videos()
->wherePivot('watched', false)
->orderBy('position')
->first();
}
// Get random video (for shuffle)
public function getRandomVideo()
{
return $this->videos()->inRandomOrder()->first();
}
// Static method to create default "Watch Later" playlist
public static function createWatchLater($userId)
{
return self::create([
'user_id' => $userId,
'name' => 'Watch Later',
'description' => 'Save videos to watch later',
'visibility' => 'private',
'is_default' => true,
]);
}
// Get or create watch later playlist for user
public static function getWatchLater($userId)
{
$playlist = self::where('user_id', $userId)
->where('is_default', true)
->first();
if (! $playlist) {
$playlist = self::createWatchLater($userId);
}
return $playlist;
}
}

View File

@ -1,55 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['user_id', 'body', 'video_id', 'image'];
public function user()
{
return $this->belongsTo(User::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
public function reactions()
{
return $this->hasMany(PostReaction::class);
}
public function postImages()
{
return $this->hasMany(PostImage::class)->orderBy('sort_order');
}
public function postVideos()
{
return $this->hasMany(PostVideo::class)->with('video')->orderBy('sort_order');
}
public function isLikedBy(?User $user): bool
{
if (! $user) return false;
return $this->reactions()->where('user_id', $user->id)->exists();
}
public function getReactionCountAttribute(): int
{
return $this->reactions()->count();
}
public function getImageUrlAttribute(): ?string
{
if (! $this->image) return null;
if (str_starts_with($this->image, 'users/')) {
return route('media.post-image', $this->image);
}
return asset('storage/post_images/' . $this->image);
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostImage extends Model
{
protected $fillable = ['post_id', 'filename', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function getImageUrlAttribute(): string
{
if (str_starts_with($this->filename, 'users/')) {
return route('media.post-image', $this->filename);
}
return asset('storage/post_images/' . $this->filename);
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostReaction extends Model
{
protected $fillable = ['user_id', 'post_id', 'type'];
public function user()
{
return $this->belongsTo(User::class);
}
public function post()
{
return $this->belongsTo(Post::class);
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostVideo extends Model
{
protected $fillable = ['post_id', 'video_id', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProfileVisit extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'profile_user_id',
'visitor_user_id',
'device_id',
'source_video_id',
'ip_address',
'country',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function profileUser(): BelongsTo
{
return $this->belongsTo(User::class, 'profile_user_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(User::class, 'visitor_user_id');
}
public function sourceVideo(): BelongsTo
{
return $this->belongsTo(Video::class, 'source_video_id');
}
}

View File

@ -1,184 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
private static array $cache = [];
public static function get(string $key, mixed $default = null): mixed
{
if (! array_key_exists($key, self::$cache)) {
$row = static::where('key', $key)->first();
self::$cache[$key] = $row ? $row->value : $default;
}
return self::$cache[$key];
}
public static function set(string $key, mixed $value): void
{
static::updateOrCreate(['key' => $key], ['value' => (string) $value]);
self::$cache[$key] = (string) $value;
}
/** Returns the configured FFmpeg binary path, falling back to the config file default. */
public static function ffmpegBinary(): string
{
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
}
/** Raw setting: is GPU encoding switched on in the admin panel? */
public static function gpuEnabled(): bool
{
return static::get('gpu_enabled', 'true') === 'true';
}
public static function gpuDevice(): int
{
return (int) static::get('gpu_device', '0');
}
/**
* Runtime gate used by every encode path: the GPU is switched on AND it is
* actually reachable and able to encode right now. Unlike gpuEnabled() this
* runs a live smoke-test (cached briefly) so a misconfigured / unplugged /
* driver-mismatched GPU automatically routes work to the CPU instead of
* producing a hung or failed encode.
*/
public static function gpuUsable(): bool
{
if (! static::gpuEnabled()) {
return false;
}
// Cache the smoke-test result briefly, but never let a cache backend problem
// (e.g. an unwritable cache dir) break an encode — fall back to a direct probe.
try {
return (bool) Cache::remember('gpu_usable_probe', 60, fn () => static::probeGpu());
} catch (\Throwable $e) {
return static::probeGpu();
}
}
/** Forget the cached probe result — call this whenever GPU settings change. */
public static function flushGpuProbe(): void
{
try {
Cache::forget('gpu_usable_probe');
} catch (\Throwable $e) {
// ignore — a missing/unwritable cache just means the next call re-probes
}
}
/**
* Smoke-test the GPU encode path. Two checks:
* 1. the device is visible to the NVIDIA driver (nvidia-smi), and
* 2. the encoder actually produces a frame.
* Step 2 is the decisive one it catches CUDA / driver-version mismatches
* that nvidia-smi cannot see. Returns true only if the GPU can really encode.
*
* @param string|null $encoder Encoder to test; defaults to the configured one.
*/
public static function probeGpu(?string $encoder = null): bool
{
$encoder = $encoder ?: static::get('gpu_encoder', 'h264_nvenc');
$device = static::gpuDevice();
$isNvenc = str_contains($encoder, 'nvenc');
// 1) Device visible to the driver? (catches unplugged card / unloaded module)
if ($isNvenc) {
@exec('nvidia-smi -i ' . (int) $device
. ' --query-gpu=name --format=csv,noheader,nounits 2>/dev/null', $smi, $smiExit);
if ($smiExit !== 0 || trim(implode('', $smi)) === '') {
Log::warning('GPU probe: device not visible via nvidia-smi — using CPU', [
'device' => $device,
]);
return false;
}
}
// 2) Encoder actually works? (encode a single throwaway frame)
$ffmpeg = static::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/gpu_probe_' . getmypid() . '_' . uniqid() . '.mp4';
$gpuArg = $isNvenc ? ' -gpu ' . (int) $device : '';
// 256x144 is the smallest 16:9 size NVENC will accept (it rejects tiny frames
// such as 128x72 with "Frame Dimension less than the minimum supported value").
@exec(
escapeshellcmd($ffmpeg)
. ' -hide_banner -loglevel error'
. ' -f lavfi -i color=c=black:s=256x144:r=1 -frames:v 1'
. ' -c:v ' . escapeshellarg($encoder) . $gpuArg
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$o, $exit
);
$ok = ($exit === 0 && is_file($tmp) && filesize($tmp) > 0);
@unlink($tmp);
if (! $ok) {
Log::warning('GPU probe: encode smoke-test failed — using CPU', [
'encoder' => $encoder,
'device' => $device,
'binary' => $ffmpeg,
]);
}
return $ok;
}
public static function gpuEncoder(): string
{
return static::gpuUsable()
? static::get('gpu_encoder', 'h264_nvenc')
: 'libx264';
}
public static function gpuPreset(): string
{
return static::gpuUsable()
? static::get('gpu_preset', 'p4')
: 'fast';
}
public static function gpuHwaccel(): string
{
return static::get('gpu_hwaccel', 'cuda');
}
/** Returns the full video codec flags for FFmpeg shell commands. */
public static function ffmpegVideoFlags(bool $stillImage = false): string
{
if (static::gpuUsable()) {
$enc = static::get('gpu_encoder', 'h264_nvenc');
$preset = static::get('gpu_preset', 'p4');
$device = static::gpuDevice();
$gpuFlag = str_contains($enc, 'nvenc') ? " -gpu {$device}" : '';
return "-c:v {$enc} -preset {$preset} -rc vbr -cq 23{$gpuFlag} -pix_fmt yuv420p";
}
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** CPU-only video codec flags — used as automatic fallback when GPU encoding fails. */
public static function ffmpegVideoFlagsCpu(bool $stillImage = false): string
{
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** Returns hwaccel decode flags when the input source is a video file. */
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
if (! $inputIsVideo || ! static::gpuUsable()) return '';
$hwaccel = static::get('gpu_hwaccel', 'cuda');
$device = static::gpuDevice();
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ShareAccess extends Model
{
public $timestamps = false;
protected $guarded = [];
public function share()
{
return $this->belongsTo(VideoShare::class, 'share_id');
}
}

View File

@ -1,51 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SportsMatch extends Model
{
use HasFactory;
protected $table = 'sports_matches';
protected $fillable = [
'video_id', 'user_id', 'status',
'sport', 'title', 'event_name', 'match_type',
'match_date', 'match_time',
'participant1_name', 'participant2_name', 'referee_name', 'venue_name',
'competition', 'participants', 'media', 'officials',
'venue', 'result', 'segments', 'statistics', 'reviews',
];
protected $casts = [
'match_date' => 'date',
'competition' => 'array',
'participants' => 'array',
'media' => 'array',
'officials' => 'array',
'venue' => 'array',
'result' => 'array',
'segments' => 'array',
'statistics' => 'array',
'reviews' => 'array',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
}

View File

@ -2,15 +2,13 @@
namespace App\Models; namespace App\Models;
use App\Notifications\VerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable
{ {
use HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
@ -21,29 +19,6 @@ class User extends Authenticatable implements MustVerifyEmail
'password', 'password',
'avatar', 'avatar',
'role', 'role',
'bio',
'website',
'twitter',
'instagram',
'facebook',
'youtube',
'linkedin',
'tiktok',
'birthday',
'location',
'gender',
'nationality',
'phone_code',
'phone_number',
'timezone',
'whatsapp',
'google_location',
'social_phone',
'social_email',
'two_factor_secret',
'two_factor_enabled',
'banner',
'notification_preferences',
]; ];
protected $hidden = [ protected $hidden = [
@ -52,82 +27,10 @@ class User extends Authenticatable implements MustVerifyEmail
]; ];
protected $casts = [ protected $casts = [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
]; ];
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
'notif_new_reply' => true,
'notif_comment_like' => true,
'notif_video_like' => true,
'notif_new_subscriber' => true,
'notif_new_video_from_sub' => true,
'notif_new_post_from_sub' => true,
'notif_new_user_reg' => true,
'email_new_comment' => true,
'email_new_reply' => true,
'email_comment_like' => false,
'email_video_like' => false,
'email_new_subscriber' => true,
'email_new_video_from_sub' => true,
'email_new_post_from_sub' => false,
'email_video_processed' => true,
'email_new_user_reg' => true,
'email_weekly_digest' => true,
];
public function notificationPref(string $key): bool
{
$prefs = $this->notification_preferences ?? [];
$default = self::NOTIF_DEFAULTS[$key] ?? true;
return isset($prefs[$key]) ? (bool) $prefs[$key] : $default;
}
public static function notifDefaults(): array
{
return self::NOTIF_DEFAULTS;
}
protected $appends = ['avatar_url', 'banner_url'];
// Auto-generate a unique slug-based username when creating a user without one
protected static function boot(): void
{
parent::boot();
static::creating(function (User $user) {
if (empty($user->username)) {
$user->username = static::generateUniqueUsername($user->name);
}
});
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
if ($base === '') {
$base = 'user';
}
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (static::where('username', $slug)->exists());
return $slug;
}
// Returns the channel URL slug — the username, auto-generated and saved if missing
public function getChannelAttribute(): string
{
if (empty($this->username)) {
$this->username = static::generateUniqueUsername($this->name);
$this->saveQuietly();
}
return $this->username;
}
// Relationships // Relationships
public function videos() public function videos()
{ {
@ -149,36 +52,12 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Comment::class); return $this->hasMany(Comment::class);
} }
public function playlists() public function getAvatarUrlAttribute()
{
return $this->hasMany(Playlist::class);
}
public function posts()
{
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute(): string
{ {
if ($this->avatar) { if ($this->avatar) {
return route('media.avatar', $this->avatar); return asset('storage/avatars/' . $this->avatar);
} }
return 'https://i.pravatar.cc/150?u=' . $this->id;
return 'https://i.pravatar.cc/150?u='.$this->id;
}
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return route('media.banner', $this->banner);
}
return null;
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmail);
} }
// Role helper methods // Role helper methods
@ -197,61 +76,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->role === 'user' || $this->role === null; return $this->role === 'user' || $this->role === null;
} }
// Users who subscribe TO this channel // Placeholder for subscriber count (would need a separate table in full implementation)
public function subscribers() public function getSubscriberCountAttribute()
{ {
return $this->belongsToMany( // For now, return a placeholder - in production this would come from a subscriptions table
User::class, return rand(100, 10000);
'user_subscriptions',
'channel_id',
'subscriber_id'
)->withPivot(['created_at', 'source_video_id']);
}
// Channels this user subscribes to
public function subscriptions()
{
return $this->belongsToMany(
User::class,
'user_subscriptions',
'subscriber_id',
'channel_id'
)->withPivot(['created_at', 'source_video_id']);
}
public function isSubscribedTo(User $channel): bool
{
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
}
public function getSubscriberCountAttribute(): int
{
return $this->subscribers()->count();
}
// Check if user has any social links
public function socialLinks()
{
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
}
public function hasSocialLinks()
{
return $this->socialLinks()->exists();
}
// Get formatted website URL
public function getWebsiteUrlAttribute()
{
if (! $this->website) {
return null;
}
// Add https:// if no protocol is specified
if (! preg_match('/^https?:\/\//', $this->website)) {
return 'https://'.$this->website;
}
return $this->website;
} }
} }

View File

@ -1,17 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserSocialLink extends Model
{
protected $fillable = ['user_id', 'platform', 'value', 'visibility', 'sort_order'];
const VISIBILITIES = ['public', 'registered', 'subscribers', 'only_me'];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -22,15 +22,6 @@ class Video extends Model
'status', 'status',
'visibility', 'visibility',
'type', 'type',
'is_shorts',
'has_hls',
'hls_path',
'download_access',
'download_count',
'share_count',
'share_token',
'slideshow_video_path',
'language',
]; ];
protected $casts = [ protected $casts = [
@ -38,8 +29,6 @@ class Video extends Model
'size' => 'integer', 'size' => 'integer',
'width' => 'integer', 'width' => 'integer',
'height' => 'integer', 'height' => 'integer',
'is_shorts' => 'boolean',
'has_hls' => 'boolean',
]; ];
// Relationships // Relationships
@ -60,112 +49,24 @@ class Video extends Model
->withTimestamps(); ->withTimestamps();
} }
public function slides()
{
return $this->hasMany(VideoSlide::class)->orderBy('position');
}
public function audioTracks()
{
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
}
public function hasSlideshow(): bool
{
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 ─────────────────────────────────────────
/**
* Absolute path to the video file on local disk.
* Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…").
*/
public function localVideoPath(): string
{
return storage_path('app/' . $this->path);
}
/**
* Absolute path to the thumbnail on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (path contains a slash, e.g. users//thumb.jpg)
*/
public function localThumbnailPath(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? storage_path('app/' . $this->thumbnail)
: storage_path('app/public/thumbnails/' . $this->thumbnail);
}
/**
* Storage::delete()-compatible key for the thumbnail.
*/
public function thumbnailStorageKey(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? $this->thumbnail
: 'public/thumbnails/' . $this->thumbnail;
}
// Accessors // Accessors
public function getUrlAttribute() public function getUrlAttribute()
{ {
return asset('storage/videos/'.$this->filename); return asset('storage/videos/' . $this->filename);
} }
public function getThumbnailUrlAttribute(): ?string public function getThumbnailUrlAttribute()
{ {
if ($this->thumbnail) { if ($this->thumbnail) {
return route('media.thumbnail', $this->thumbnail); return asset('storage/thumbnails/' . $this->thumbnail);
} }
return asset('images/video-placeholder.jpg');
return null;
} }
// Check if video is liked by user // Check if video is liked by user
public function isLikedBy($user) public function isLikedBy($user)
{ {
if (! $user) { if (!$user) return false;
return false;
}
return $this->likes()->where('user_id', $user->id)->exists(); return $this->likes()->where('user_id', $user->id)->exists();
} }
@ -181,111 +82,10 @@ class Video extends Model
return \DB::table('video_views')->where('video_id', $this->id)->count(); return \DB::table('video_views')->where('video_id', $this->id)->count();
} }
// ── Short opaque URL encoding ───────────────────────────────────── // Get shareable URL for the video
// 3-round Feistel cipher over 30-bit space → base62.
// Consecutive IDs produce completely different output. Pure PHP, no extensions.
private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static function feistelRound(int $n, int $round): int
{
// Deterministic pseudo-random function per round (no extension needed)
$keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083];
return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF);
}
public static function encodeId(int $id): string
{
// Split 30-bit id into two 15-bit halves
$L = ($id >> 15) & 0x7FFF;
$R = $id & 0x7FFF;
// 3 Feistel rounds
for ($i = 0; $i < 3; $i++) {
$tmp = $L ^ self::feistelRound($R, $i);
$L = $R;
$R = $tmp;
}
$n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
$base = strlen(self::URL_ALPHABET);
$out = '';
do {
$out = self::URL_ALPHABET[$n % $base] . $out;
$n = intdiv($n, $base);
} while ($n > 0);
// Pad to fixed length so all URLs look uniform
return str_pad($out, 5, '0', STR_PAD_LEFT);
}
public static function decodeId(string $encoded): ?int
{
$base = strlen(self::URL_ALPHABET);
$n = 0;
for ($i = 0, $len = strlen($encoded); $i < $len; $i++) {
$pos = strpos(self::URL_ALPHABET, $encoded[$i]);
if ($pos === false) return null;
$n = $n * $base + $pos;
}
// Reverse Feistel
$L = ($n >> 15) & 0x7FFF;
$R = $n & 0x7FFF;
for ($i = 2; $i >= 0; $i--) {
$tmp = $R ^ self::feistelRound($L, $i);
$R = $L;
$L = $tmp;
}
return (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
}
// Route model binding — use encoded ID in URLs
public function getRouteKey()
{
return self::encodeId($this->id);
}
public function getRouteKeyName()
{
return 'id';
}
public function resolveRouteBinding($value, $field = null)
{
$realId = self::decodeId((string) $value);
if ($realId === null || $realId <= 0) {
abort(404);
}
return $this->where('id', $realId)->firstOrFail();
}
// All share URLs use the unguessable token route
public function getShareUrlAttribute() public function getShareUrlAttribute()
{ {
return route('videos.showByToken', $this->share_token); return route('videos.show', $this->id);
}
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
public function getFormattedDurationAttribute()
{
if (! $this->duration) {
return null;
}
$minutes = floor($this->duration / 60);
$seconds = $this->duration % 60;
return sprintf('%d:%02d', $minutes, $seconds);
}
// Get Shorts badge
public function getShortsBadgeAttribute()
{
if ($this->isShorts()) {
return [
'icon' => 'bi-collection-play-fill',
'label' => 'SHORTS',
'color' => '#ff0000',
];
}
return null;
} }
// Visibility helpers // Visibility helpers
@ -335,17 +135,16 @@ class Video extends Model
if ($user) { if ($user) {
return $query->where(function ($q) use ($user) { return $query->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id); ->orWhere('user_id', $user->id);
}); });
} }
return $query->where('visibility', '!=', 'private'); return $query->where('visibility', '!=', 'private');
} }
// Video type helpers // Video type helpers
public function getTypeIconAttribute() public function getTypeIconAttribute()
{ {
return match ($this->type) { return match($this->type) {
'music' => 'bi-music-note', 'music' => 'bi-music-note',
'match' => 'bi-trophy', 'match' => 'bi-trophy',
default => 'bi-film', default => 'bi-film',
@ -354,7 +153,7 @@ class Video extends Model
public function getTypeSymbolAttribute() public function getTypeSymbolAttribute()
{ {
return match ($this->type) { return match($this->type) {
'music' => '🎵', 'music' => '🎵',
'match' => '🏆', 'match' => '🏆',
default => '🎬', default => '🎬',
@ -376,37 +175,6 @@ class Video extends Model
return $this->type === 'match'; return $this->type === 'match';
} }
public function isAudioOnly(): bool
{
$ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION));
return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']);
}
// Shorts helpers
public function isShorts()
{
return $this->is_shorts === true || $this->is_shorts === 1 || $this->is_shorts === '1';
}
// Scope for shorts videos
public function scopeShorts($query)
{
return $query->where('is_shorts', true);
}
// Scope for non-shorts videos
public function scopeNotShorts($query)
{
return $query->where('is_shorts', false);
}
// Check if video qualifies as shorts (auto-detection)
public function qualifiesAsShorts()
{
// Shorts: duration <= 60 seconds AND portrait orientation
return $this->duration <= 60 && $this->orientation === 'portrait';
}
// Comments relationship // Comments relationship
public function comments() public function comments()
{ {
@ -417,159 +185,4 @@ class Video extends Model
{ {
return $this->comments()->count(); return $this->comments()->count();
} }
// Match events relationships
public function matchRounds()
{
return $this->hasMany(MatchRound::class)->orderBy('round_number');
}
public function matchPoints()
{
return $this->hasMany(MatchPoint::class);
}
public function coachReviews()
{
return $this->hasMany(CoachReview::class)->orderBy('start_time_seconds');
}
// Get recent views count (within hours)
public function getRecentViews($hours = 48)
{
return \DB::table('video_views')
->where('video_id', $this->id)
->where('watched_at', '>=', now()->subHours($hours))
->count();
}
// Get views in last 24 hours (for velocity calculation)
public function getViewsLast24Hours()
{
return $this->getRecentViews(24);
}
// Calculate trending score (YouTube-style algorithm)
public function getTrendingScore($hours = 48)
{
$recentViews = $this->getRecentViews($hours);
// Don't include videos older than 10 days
if ($this->created_at->diffInDays(now()) > 10) {
return 0;
}
// Don't include videos with no recent views
if ($recentViews < 5) {
return 0;
}
// Calculate view velocity (views per hour in last 48 hours)
$velocity = $recentViews / $hours;
// Recency bonus: newer videos get a boost
$ageHours = $this->created_at->diffInHours(now());
$recencyBonus = max(0, 1 - ($ageHours / 240)); // Decreases over 10 days
// Like count bonus
$likeBonus = $this->like_count * 0.1;
// Calculate final score
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
$score = ($recentViews * 0.70) +
($velocity * 100 * 0.15) +
($recencyBonus * 50 * 0.10) +
($likeBonus * 0.05);
return round($score, 2);
}
// Get thumbnail dimensions for Open Graph
public function getThumbnailWidthAttribute()
{
// Default OG recommended size is 1200x630
return $this->width ?? 1280;
}
public function getThumbnailHeightAttribute()
{
// Default OG recommended size is 1200x630
return $this->height ?? 720;
}
// Get video stream URL for Open Graph
public function getStreamUrlAttribute()
{
return route('videos.stream', $this);
}
// Get secure share URL
public function getSecureShareUrlAttribute()
{
return secure_url(route('videos.show', $this));
}
// Get secure thumbnail URL
public function getSecureThumbnailUrlAttribute()
{
if ($this->thumbnail) {
return secure_asset('storage/thumbnails/'.$this->thumbnail);
}
return null;
}
// Get full thumbnail URL with dimensions for Open Graph
public function getOpenGraphImageAttribute()
{
$thumbnail = $this->thumbnail_url;
// Add cache busting for dynamic thumbnails
if ($this->thumbnail) {
$thumbnail .= '?v='.$this->updated_at->timestamp;
}
return $thumbnail;
}
// Get author/uploader name
public function getAuthorNameAttribute()
{
return $this->user ? $this->user->name : config('app.name');
}
// Get video duration in ISO 8601 format for Open Graph
public function getIsoDurationAttribute()
{
if (! $this->duration) {
return null;
}
$hours = floor($this->duration / 3600);
$minutes = floor(($this->duration % 3600) / 60);
$seconds = $this->duration % 60;
if ($hours > 0) {
return sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
} elseif ($minutes > 0) {
return sprintf('PT%dM%dS', $minutes, $seconds);
}
return sprintf('PT%dS', $seconds);
}
// Scope for trending videos
public function scopeTrending($query, $hours = 48, $limit = 50)
{
return $query->public()
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->orderByDesc(\DB::raw('(
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) * 0.70 +
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) / '.$hours.' * 100 * 0.15 +
GREATEST(0, TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
)'))
->limit($limit);
}
} }

View File

@ -1,33 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VideoAudioTrack extends Model
{
protected $fillable = ['video_id', 'language', 'label', 'title', 'description', 'path', 'filename'];
public function video(): BelongsTo
{
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]);
}
/** Absolute local path to the file, regardless of NAS or local storage. */
public function localPath(): string
{
return storage_path('app/' . $this->path);
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoShare extends Model
{
public $timestamps = false;
protected $guarded = [];
public function video()
{
return $this->belongsTo(Video::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function accesses()
{
return $this->hasMany(ShareAccess::class, 'share_id');
}
}

View File

@ -1,47 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model
{
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);
}
/**
* Absolute path to the slide image on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (filename contains a slash)
*/
public function localPath(): string
{
return str_contains($this->filename, '/')
? storage_path('app/' . $this->filename)
: storage_path('app/public/thumbnails/' . $this->filename);
}
/**
* Storage::delete()-compatible key for this slide file.
*/
public function storageKey(): string
{
return str_contains($this->filename, '/')
? $this->filename
: 'public/thumbnails/' . $this->filename;
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentLikeNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $liker
) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_comment_like')) $ch[] = 'database';
if ($notifiable->notificationPref('email_comment_like')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'comment_like',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->liker->id,
'actor_name' => $this->liker->name,
'actor_avatar' => $this->liker->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->liker->name . ' liked your comment')
->view('emails.comment-liked', [
'video' => $this->video,
'comment' => $this->comment,
'liker' => $this->liker,
'recipient'=> $notifiable,
]);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $commenter
) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_comment')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_comment')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_comment',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->commenter->id,
'actor_name' => $this->commenter->name,
'actor_avatar' => $this->commenter->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->commenter->name . ' commented on your video')
->view('emails.new-comment', [
'video' => $this->video,
'comment' => $this->comment,
'commenter'=> $this->commenter,
'recipient'=> $notifiable,
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Post;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewPostNotification extends Notification
{
public function __construct(public Post $post, public User $author) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_post_from_sub')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_post_from_sub')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_post',
'post_id' => $this->post->id,
'author_id' => $this->author->id,
'author_name' => $this->author->name,
'author_avatar' => $this->author->avatar_url,
'author_channel' => $this->author->channel,
'post_preview' => Str::limit(strip_tags($this->post->body ?? ''), 100),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->author->name . ' posted something new')
->view('emails.new-post-from-sub', [
'post' => $this->post,
'author' => $this->author,
'recipient' => $notifiable,
]);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewReplyNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $reply,
public User $replier
) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_reply')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_reply')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_reply',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->replier->id,
'actor_name' => $this->replier->name,
'actor_avatar' => $this->replier->avatar_url,
'comment_preview' => Str::limit($this->reply->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->replier->name . ' replied to your comment')
->view('emails.new-reply', [
'video' => $this->video,
'reply' => $this->reply,
'replier' => $this->replier,
'recipient'=> $notifiable,
]);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewSubscriberNotification extends Notification
{
public function __construct(public User $subscriber) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_subscriber')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_subscriber')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_subscriber',
'actor_id' => $this->subscriber->id,
'actor_name' => $this->subscriber->name,
'actor_avatar' => $this->subscriber->avatar_url,
'actor_channel' => $this->subscriber->channel,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->subscriber->name . ' subscribed to your channel')
->view('emails.new-subscriber', [
'subscriber' => $this->subscriber,
'recipient' => $notifiable,
]);
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewUserRegistered extends Notification
{
public function __construct(public User $newUser) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_user_reg')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_user_reg')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_user',
'user_id' => $this->newUser->id,
'user_name' => $this->newUser->name,
'user_email' => $this->newUser->email,
'user_avatar' => $this->newUser->avatar_url,
'user_channel' => $this->newUser->channel,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('New member joined TAKEONE 🎉')
->view('emails.new-user-registered', [
'newUser' => $this->newUser,
'admin' => $notifiable,
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Video;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewVideoUploaded extends Notification
{
public function __construct(public Video $video, public User $uploader) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_video_from_sub')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_video_from_sub')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_video',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'uploader_id' => $this->uploader->id,
'uploader_name' => $this->uploader->name,
'uploader_avatar' => $this->uploader->avatar_url,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->uploader->name . ' uploaded a new video')
->view('emails.new-video-from-sub', [
'video' => $this->video,
'uploader' => $this->uploader,
'recipient'=> $notifiable,
]);
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends BaseVerifyEmail
{
use Queueable;
public function toMail($notifiable): MailMessage
{
$url = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify your email address — ' . config('app.name'))
->view('emails.verify-email', [
'url' => $url,
'userName' => $notifiable->name,
]);
}
protected function verificationUrl($notifiable): string
{
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VideoLikedNotification extends Notification
{
public function __construct(public Video $video, public User $liker) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_video_like')) $ch[] = 'database';
if ($notifiable->notificationPref('email_video_like')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'video_like',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->liker->id,
'actor_name' => $this->liker->name,
'actor_avatar' => $this->liker->avatar_url,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->liker->name . ' liked your video')
->view('emails.video-liked', [
'video' => $this->video,
'liker' => $this->liker,
'recipient'=> $notifiable,
]);
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VideoSharedWithUser extends Notification
{
public function __construct(
public Video $video,
public User $sharer,
public ?string $message,
public string $shareUrl,
) {}
/**
* A direct, person-to-person share is transactional always deliver both
* the in-app notification and the email (not gated by subscription prefs).
*/
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'video_shared',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->sharer->id,
'actor_name' => $this->sharer->name,
'actor_avatar' => $this->sharer->avatar_url,
'message' => $this->message,
];
}
public function toMail(object $notifiable): MailMessage
{
$isSong = $this->video->isAudioOnly() || $this->video->type === 'music';
$noun = $isSong ? 'song' : ($this->video->type === 'match' ? 'match' : 'video');
return (new MailMessage)
->subject($this->sharer->name . ' shared a ' . $noun . ' with you')
->view('emails.video-shared', [
'video' => $this->video,
'sender' => $this->sharer,
'shareUrl' => $this->shareUrl,
'personalMessage' => $this->message,
'shareTitle' => $this->video->title,
]);
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\DB;
class WeeklyDigestNotification extends Notification
{
public array $stats;
public function __construct(public User $creator)
{
$videoIds = $creator->videos()->pluck('id');
$views = DB::table('video_views')
->whereIn('video_id', $videoIds)
->where('created_at', '>=', now()->subWeek())
->count();
$likes = DB::table('video_likes')
->whereIn('video_id', $videoIds)
->where('created_at', '>=', now()->subWeek())
->count();
$newSubs = DB::table('user_subscriptions')
->where('channel_id', $creator->id)
->where('created_at', '>=', now()->subWeek())
->count();
$topVideo = $creator->videos()
->withCount(['viewers as week_views' => fn($q) => $q->where('video_views.created_at', '>=', now()->subWeek())])
->orderByDesc('week_views')
->first();
$this->stats = [
'views' => $views,
'likes' => $likes,
'new_subs' => $newSubs,
'top_video' => $topVideo,
];
}
public function via(object $notifiable): array
{
return $notifiable->notificationPref('email_weekly_digest') ? ['mail'] : [];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your weekly TAKEONE summary 📊')
->view('emails.weekly-digest', [
'creator' => $this->creator,
'stats' => $this->stats,
]);
}
}

View File

@ -5,7 +5,6 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -28,31 +27,5 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS // Force HTTPS
URL::forceScheme('https'); URL::forceScheme('https');
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
Paginator::defaultSimpleView('partials.pagination');
// Merge NAS credentials stored in the DB into the package config at runtime.
// This way the live browser and all NAS operations use DB values without needing .env.
$this->app->booted(function () {
try {
$host = \App\Models\Setting::get('nas_host', '');
if ($host) {
config([
'nas-file-manager.connection.protocol' => \App\Models\Setting::get('nas_protocol', 'smb'),
'nas-file-manager.connection.host' => $host,
'nas-file-manager.connection.port' => (int) \App\Models\Setting::get('nas_port', 445),
'nas-file-manager.connection.username' => \App\Models\Setting::get('nas_username', ''),
'nas-file-manager.connection.password' => \App\Models\Setting::get('nas_password', ''),
'nas-file-manager.connection.path' => \App\Models\Setting::get('nas_path', '/media'),
'nas-file-manager.connection.smb_share' => \App\Models\Setting::get('nas_smb_share', ''),
'nas-file-manager.connection.smb_domain' => \App\Models\Setting::get('nas_smb_domain', ''),
]);
}
} catch (\Throwable $e) {
// DB may not exist yet (fresh install / migrations not run) — silently skip
}
});
} }
} }

View File

@ -1,141 +0,0 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NotDisposableEmail implements ValidationRule
{
// Top disposable/throwaway email domains
private const BLOCKED = [
'0-mail.com','0815.ru','0clickemail.com','0wnd.net','0wnd.org','10minutemail.com',
'10minutemail.net','10minutemail.org','10minutemail.de','10mail.org','20minutemail.com',
'2prong.com','30minutemail.com','33mail.com','3d-painting.com','4warding.com',
'amilegit.com','anonbox.net','anonymail.net','anonymbox.com','antichef.com',
'antichef.net','antireg.com','antispam.de','antispammail.de','armyspy.com',
'baxomale.ht.cx','beefmilk.com','bigstring.com','binkmail.com','bio-muesli.net',
'bobmail.info','bodhi.lawlita.com','bofthew.com','bootybay.de','bossmail.de',
'bouncr.com','breakthru.com','brefmail.com','broadbandninja.com','bsnow.net',
'bugmenot.com','bumpymail.com','casualdx.com','centermail.com','centermail.net',
'chacuo.net','chammy.info','childsavetrust.org','chogmail.com','choplife.com',
'clixser.com','cmail.net','coiico.com','cool.fr.nf','correo.blogos.net',
'crapmail.org','crossroadsmail.com','curryworld.de','cust.in','dacoolest.com',
'dandikmail.com','dayrep.com','dcemail.com','deadaddress.com','deadletter.ga',
'deagot.com','dealja.com','despam.it','devnullmail.com','dfgh.net',
'digitalsanctuary.com','discardmail.com','discardmail.de','disposableaddress.com',
'disposableinbox.com','disposablemail.net','disposablemail.org','disposablemail.top',
'dispostable.com','dk4f9h7y.fun','dodgeit.com','dodgit.com','donemail.ru',
'dontreg.com','dontsendmespam.de','drdrb.com','drdrb.net','dropmail.me',
'dumpandfuck.com','dumpmail.de','dumpyemail.com','e4ward.com','easytrashmail.com',
'email60.com','emailfake.com','emailias.com','emailigo.com','emailinfive.com',
'emailisvalid.com','emaillime.com','emailmiser.com','emailna.com','emailnax.com',
'emailnew.com','emailo.pro','emailondeck.com','emailthe.net','emailtmp.com',
'emailwarden.com','emailx.at.hm','emailxfer.com','emeil.in','emeil.ir',
'emlhub.com','emltmp.com','enterto.com','ephemail.net','etranquil.com',
'evopo.com','explodemail.com','express.net.ua','eyepaste.com','fakeinbox.com',
'fakeinformation.com','fakemail.fr','fakemailgenerator.com','fakemail.net',
'fastacura.com','fastchevy.com','fastchrysler.com','fastkawasaki.com','fastmazda.com',
'fastmitsubishi.com','fastnissan.com','fastsubaru.com','fastsuzuki.com','fasttoyota.com',
'fastyamaha.com','fatflap.com','fdcserver.net','fightallspam.com','filzmail.com',
'fixmail.tk','fizmail.com','fleckens.hu','flemail.com','frapmail.com',
'friendlymail.co.uk','fuckingduh.com','fudgerub.com','fux0ringduh.com','fyxm.net',
'garliclife.com','gehensiemirnichtaufdensack.de','gelitik.in','get2mail.fr',
'getairmail.com','getonemail.com','giantmail.de','girlsundertheinfluence.com',
'gishpuppy.com','gmailnew.com','goemailgo.com','gorillaswithdirtyarmpits.com',
'gotmail.net','gotmail.org','gowikibooks.com','gowikicampus.com','gowikicars.com',
'gowikifilms.com','gowikigames.com','gowikimusic.com','gowikinetwork.com',
'gowikitravel.com','grr.la','guerillamail.biz','guerillamail.com','guerillamail.de',
'guerillamail.info','guerillamail.net','guerillamail.org','guerrillamail.biz',
'guerrillamail.com','guerrillamail.de','guerrillamail.info','guerrillamail.net',
'guerrillamail.org','guerrillamailblock.com','gustr.com','h.mintemail.com',
'haltospam.com','haqed.com','harakirimail.com','hat-geld.de','hatespam.org',
'herp.in','hidemail.de','high.net','hmamail.com','hopemail.biz',
'hulapla.de','ieatspam.eu','ieatspam.info','ieh-mail.de','ihateyoualot.info',
'iheartspam.org','ikbenspamvrij.nl','imails.info','inboxclean.com','inboxclean.org',
'incognitomail.net','incognitomail.org','insorg.org','internet-e-mail.de','jetable.fr',
'jetable.net','jetable.org','jnxjn.com','joggly.com','junk.to',
'justamail.net','kasmail.com','kaspop.com','killmail.com','killmail.net',
'klzlk.com','koszmail.pl','kurzepost.de','laoeq.com','lavabit.com',
'letthemeatspam.com','lhsdv.com','lifebyfood.com','lol.ovpn.to','lookugly.com',
'lopl.co.cc','lortemail.dk','lovemeleaveme.com','lr78.com','lukop.dk',
'm4ilweb.info','maboard.com','mail-filter.com','mail-temporaire.com','mail.by',
'mail1a.de','mail2rss.org','mail333.com','mailbidon.com','mailblocks.com',
'mailbucket.org','mailchop.com','maileater.com','maileimer.de','mailexpire.com',
'mailfa.tk','mailforspam.com','mailfree.ga','mailguard.me','mailimate.com',
'mailinatar.com','mailinater.com','mailinator.com','mailinator.net','mailinator.org',
'mailinator.us','mailinator2.com','mailincubator.com','mailismagic.com','mailjunk.cf',
'mailjunk.ga','mailjunk.gq','mailjunk.ml','mailjunk.tk','mailme.gq',
'mailme.ir','mailme24.com','mailmetrash.com','mailmoat.com','mailms.com',
'mailnull.com','mailpick.biz','mailproxsy.com','mailquack.com','mailrock.biz',
'mailscrap.com','mailseal.de','mailshell.com','mailsiphon.com','mailslapping.com',
'mailslite.com','mailsoul.com','mailsucker.net','mailtemp.info','mailtome.de',
'mailtothis.com','mailtrash.net','mailtv.net','mailzilla.com','makemetheking.com',
'mbx.cc','mega.zik.dj','meinspamschutz.de','meltmail.com','messagebeamer.de',
'mezimages.net','mintemail.com','moncourrier.fr.nf','monemail.fr.nf','monmail.fr.nf',
'msa.minsmail.com','msgos.com','mt2014.com','mt2015.com','mucincanon.com',
'mucke.de','mugglenet.com','myfastmail.com','mymailoasis.com','mynetstore.de',
'netzidiot.de','neverbox.com','nice-4u.com','nincsmail.hu','nmail.cf',
'nnot.net','nobulk.com','noclickemail.com','nodezine.com','nomail.pw',
'nomail.xl.cx','nomail2me.com','nomoremail.net','nospam.ze.tc','nospam4.us',
'nospamfor.us','nospammail.net','nospamthanks.info','notmailinator.com','nowmymail.com',
'odnorazovoe.ru','onewaymail.com','online.ms','opayq.com','ordinaryamerican.net',
'ovpn.to','owlpic.com','pecinan.com','pecinan.net','pecinan.org',
'pepbot.com','pfui.ru','pimpedupmyspace.com','plexolan.de','pookmail.com',
'privacy.net','proxymail.eu','prtnx.com','purelymail.com','putthisinyourspamdatabase.com',
'qq.com.de','quickinbox.com','rcpt.at','recode.me','recursor.net',
'regbypass.com','rklips.com','rmqkr.net','rppkn.com','rtrtr.com',
'runbox.com','s0ny.net','safe-mail.net','safetymail.info','safetypost.de',
'sandelf.de','saynotospams.com','schafmail.de','schrott-email.de','secretemail.de',
'secure-mail.biz','sexyalwasmi.top','sharedmailbox.org','sharklasers.com','shieldedmail.com',
'shiftmail.com','shitmail.de','shitmail.me','shitmail.org','shitware.nl',
'shortmail.net','sibmail.com','skeefmail.com','slaskpost.se','slopsbox.com',
'smellfear.com','snakemail.com','sneakemail.com','snkmail.com','sofimail.com',
'sogetthis.com','sohu.com.de','soisz.com','spam.la','spam.org.tr',
'spam.su','spam4.me','spamavert.com','spambob.com','spambob.net',
'spambob.org','spambog.com','spambog.de','spambog.ru','spambox.info',
'spambox.us','spamcannon.com','spamcannon.net','spamcero.com','spamcon.org',
'spamcorptastic.com','spamcowboy.com','spamcowboy.net','spamcowboy.org','spamday.com',
'spamex.com','spamfree.eu','spamfree24.de','spamfree24.eu','spamfree24.info',
'spamfree24.net','spamfree24.org','spamgourmet.com','spamgourmet.net','spamgourmet.org',
'spamherelots.com','spamhereplease.com','spamhole.com','spamify.com','spaminator.de',
'spamkill.info','spaml.com','spaml.de','spammotel.com','spamoff.de',
'spamsalad.in','spamslicer.com','spamspot.com','spamstack.net','spamthis.co.uk',
'spamthisplease.com','spamtrail.com','spamtroll.net','speed.1s.fr','speedymail.net',
'spoofmail.de','super-auswahl.de','supergreatmail.com','supermailer.jp','superrito.com',
'superstachel.de','suremail.info','sweetxxx.de','tafmail.com','tagyourself.com',
'teleworm.com','teleworm.us','temp-mail.io','temp-mail.org','temp-mail.ru',
'temp.bartbot.com','tempail.com','tempalias.com','tempe-mail.com','tempemailaddress.com',
'tempinbox.co.uk','tempinbox.com','tempmail.it','tempmail.net','tempmail.us',
'tempmail2.com','tempomail.fr','temporaryemail.net','temporaryemail.us','temporaryforwarding.com',
'temporaryinbox.com','temporarymail.org','tempsky.com','thankyou2010.com','thc.st',
'thelimestones.com','thisisnotmyrealemail.com','throam.com','throwam.com','throwaway.email',
'throwam.com','throwaway.email','throwam.com','tilien.com','tinyurl24.com',
'tmailinator.com','toiea.com','toomail.biz','top9top.com','tradermail.info',
'trash-amil.com','trash-mail.at','trash-mail.com','trash-mail.de','trash-mail.io',
'trash-mail.net','trash2009.com','trash2010.com','trash2011.com','trashcanmail.com',
'trashdevil.com','trashdevil.de','trashemail.de','trashimail.com','trashmail.at',
'trashmail.com','trashmail.es','trashmail.io','trashmail.me','trashmail.net',
'trashmail.org','trashmailer.com','trashpanda.de','trashymail.com','trillianpro.com',
'turual.com','twinmail.de','tyldd.com','uggsrock.com','upliftnow.com',
'uroid.com','username.e4ward.com','venompen.com','veryrealemail.com','vidchart.com',
'viditag.com','viewcastmedia.com','viewcastmedia.net','viewcastmedia.org','vomoto.com',
'vubby.com','walala.org','watch-harry-potter.com','wetrainbayarea.com','wetrainbayarea.org',
'wilemail.com','willhackforfood.biz','willselfdestruct.com','wmail.cf','wolfsmail.tk',
'wuzupmail.net','xagloo.com','xemaps.com','xents.com','xMailer.net',
'xmaily.com','xn--9kq967o.com','xoxox.cc','xperiae5.com','xyzfree.net',
'yahomail.org','yapped.net','yepmail.net','yourdomain.com','yopmail.com',
'yopmail.fr','yopmail.net','youremail.cf','yourewronghello.com','z1p.biz',
'za.com','zippymail.info','zoemail.net','zoemail.org','zomg.info',
'zxcv.com','zxcvbnm.com','zzz.com',
];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = strtolower(trim(substr(strrchr($value, '@'), 1)));
if (in_array($domain, self::BLOCKED, true)) {
$fail('Registrations from disposable email addresses are not allowed.');
}
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private static array $privateRanges = [
'127.', '::1', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.',
'192.168.',
];
public static function lookup(string $ip): array
{
foreach (self::$privateRanges as $prefix) {
if (str_starts_with($ip, $prefix)) {
return ['country' => null, 'country_name' => null];
}
}
return Cache::remember('geoip_' . md5($ip), 86400, function () use ($ip) {
try {
$response = Http::timeout(3)->get(
"http://ip-api.com/json/{$ip}",
['fields' => 'status,country,countryCode']
);
if ($response->successful() && $response->json('status') === 'success') {
return [
'country' => $response->json('countryCode'),
'country_name' => $response->json('country'),
];
}
} catch (\Throwable $e) {
Log::debug('GeoIP lookup failed for ' . $ip . ': ' . $e->getMessage());
}
return ['country' => null, 'country_name' => null];
});
}
}

View File

@ -1,266 +0,0 @@
<?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 24 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 56 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
. " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
. " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
. "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
. " re-spelling, no script conversion. Preserve every original word verbatim.\n"
. "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
. " designed lyric post or viral TikTok caption.\n"
. "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
. " must map to exactly one output line, in the same order, with the same words.\n\n"
. "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
. "decorated line text. No prose, no markdown, no code fence.\n\n"
. "LINES:\n" . implode("\n", $numbered);
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = [];
foreach ($obj as $k => $v) {
if (! is_string($v)) continue;
$v = trim($v);
if ($v === '') continue;
$idx = ((int) $k) - 1;
if ($idx < 0 || ! isset($lines[$idx])) continue;
// Cheap safety check: the original words must survive verbatim
// (the LLM should only LAYER emojis on top). Drop the
// decoration if too many original characters are missing.
if (! self::preservesOriginal($lines[$idx], $v)) continue;
$out[$idx] = $v;
}
return $out;
});
}
/**
* Verify the decorated line still contains every alphanumeric character of
* the original (in the same order). Stops the LLM from quietly rewording
* a line we keep only decorations that strictly add emojis on top.
*/
private static function preservesOriginal(string $orig, string $decorated): bool
{
$strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $strip($orig);
$b = $strip($decorated);
if ($a === '') return true;
// Sequential subsequence check: every char of $a must appear in $b in order.
$aLen = mb_strlen($a); $bLen = mb_strlen($b);
$j = 0;
for ($i = 0; $i < $aLen; $i++) {
$needle = mb_substr($a, $i, 1);
$found = false;
for (; $j < $bLen; $j++) {
if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
}
if (! $found) return false;
}
return true;
}
/** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string
{
$p = $this->activeProvider();
if (! $p) return '';
try {
return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
};
} catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
return '';
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
]);
if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['message']['content'] ?? '');
}
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([
'x-api-key' => (string) $p['api_key'],
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
if (! $resp->successful()) {
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
->acceptJson()
->post($endpoint . '/v1/chat/completions', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['choices'][0]['message']['content'] ?? '');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,59 +0,0 @@
<?php
namespace App\Support;
use App\Models\Video;
use App\Services\NasSyncService;
/**
* Resolves a video's thumbnail to a local absolute file path so it can be
* embedded inline (CID) in transactional emails.
*
* Remote mail clients (notably Gmail's image proxy) cannot reliably fetch the
* dynamic /media/thumbnails route it sits behind Cloudflare and PHP, so the
* proxy may be challenged or time out, leaving a broken image. Embedding the
* bytes directly in the message removes that external dependency entirely.
*/
class EmailThumbnail
{
public static function localPath(?Video $video): ?string
{
if (! $video || ! $video->thumbnail) {
return null;
}
$thumb = $video->thumbnail;
$nas = app(NasSyncService::class);
// NAS-mirrored path format: "users/{slug}/videos/{song}/…"
if (str_starts_with($thumb, 'users/')) {
$local = storage_path('app/' . $thumb);
if (file_exists($local)) {
return $local;
}
@mkdir(dirname($local), 0755, true);
// The DB path mirrors the NAS path exactly — try it directly first.
if ($nas->ensureLocalAsset($local, $thumb)) {
return $local;
}
// Extension may differ on NAS (e.g. canonical thumb.webp).
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($thumb, PATHINFO_EXTENSION) ?: 'jpg';
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) {
return $local;
}
}
return file_exists($local) ? $local : null;
}
// Legacy flat filename format.
$local = storage_path('app/public/thumbnails/' . $thumb);
return file_exists($local) ? $local : null;
}
}

View File

@ -1,223 +0,0 @@
<?php
namespace App\Support;
use DOMDocument;
use DOMElement;
use DOMNode;
/**
* Allowlist-based HTML sanitizer for rich-text descriptions.
*
* Descriptions are authored in a self-hosted contenteditable editor and stored
* as HTML. clean() strips everything not on the allowlist before storage;
* render() produces safe display HTML (and upgrades legacy plain-text values).
*/
class HtmlSanitizer
{
/** Tags allowed in stored description HTML. */
private const ALLOWED_TAGS = [
'p', 'br', 'div', 'span',
'b', 'strong', 'i', 'em', 'u', 's', 'strike',
'h2', 'h3',
'ul', 'ol', 'li',
'blockquote', 'a',
];
/** CSS class values permitted on <a> (button-link styling). */
private const ALLOWED_CLASSES = [
'action-btn', 'action-btn-primary', 'action-btn-danger',
'action-btn-link', 'primary', 'danger', 'icon-only',
];
/** URL schemes permitted in href. */
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];
/** Tags removed wholesale, including their text content. */
private const DROP_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option', 'link', 'meta',
'svg', 'math', 'noscript', 'template',
];
/**
* Sanitize untrusted HTML down to the allowlist, for storage.
*/
public static function clean(?string $html): string
{
$html = trim((string) $html);
if ($html === '') {
return '';
}
// Wrap so DOMDocument has a single root and a known encoding.
$wrapped = '<?xml encoding="UTF-8"><div id="__rte_root__">' . $html . '</div>';
$doc = new DOMDocument();
$libxmlPrev = libxml_use_internal_errors(true);
$doc->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($libxmlPrev);
$root = $doc->getElementById('__rte_root__');
if (!$root) {
return '';
}
self::sanitizeChildren($root);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$out .= $doc->saveHTML($child);
}
return self::tidy($out);
}
/**
* Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean().
*/
public static function render(?string $value): string
{
$value = (string) $value;
if (trim($value) === '') {
return '';
}
if (strip_tags($value) === $value) {
return nl2br(e($value), false);
}
return self::clean($value);
}
private static function sanitizeChildren(DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof DOMElement) {
self::sanitizeElement($child);
} elseif ($child->nodeType === XML_COMMENT_NODE) {
$child->parentNode->removeChild($child);
}
// Text nodes are kept as-is (saveHTML re-encodes them safely).
}
}
private static function sanitizeElement(DOMElement $el): void
{
$tag = strtolower($el->nodeName);
if (in_array($tag, self::DROP_TAGS, true)) {
$el->parentNode->removeChild($el);
return;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
// Unwrap: keep sanitized children, drop the disallowed wrapper.
self::sanitizeChildren($el);
$parent = $el->parentNode;
while ($el->firstChild) {
$parent->insertBefore($el->firstChild, $el);
}
$parent->removeChild($el);
return;
}
// Strip every attribute, then re-add only the allowed ones.
$keep = [];
$alignAttr = null;
foreach (iterator_to_array($el->attributes) as $attr) {
$name = strtolower($attr->nodeName);
$val = $attr->nodeValue;
if ($name === 'align') {
$a = strtolower(trim($val));
if (in_array($a, ['left', 'right', 'center', 'justify'], true)) {
$alignAttr = $a;
}
} elseif ($name === 'href' && $tag === 'a') {
$href = self::safeUrl($val);
if ($href !== null) {
$keep['href'] = $href;
}
} elseif ($name === 'class') {
$classes = array_values(array_intersect(
preg_split('/\s+/', trim($val)) ?: [],
self::ALLOWED_CLASSES
));
if ($classes) {
$keep['class'] = implode(' ', $classes);
}
} elseif ($name === 'style') {
$style = self::safeStyle($val);
if ($style !== '') {
$keep['style'] = $style;
}
} elseif ($name === 'target' && $tag === 'a') {
if ($val === '_blank') {
$keep['target'] = '_blank';
}
}
}
// Fold a legacy align="" attribute into the text-align style.
if ($alignAttr !== null && !str_contains($keep['style'] ?? '', 'text-align')) {
$keep['style'] = trim(($keep['style'] ?? '') . ';text-align:' . $alignAttr, ';');
}
while ($el->attributes->length) {
$el->removeAttribute($el->attributes->item(0)->nodeName);
}
foreach ($keep as $name => $val) {
$el->setAttribute($name, $val);
}
// Any link opening a new tab gets rel protection.
if ($tag === 'a' && ($keep['target'] ?? '') === '_blank') {
$el->setAttribute('rel', 'noopener noreferrer');
}
self::sanitizeChildren($el);
}
private static function safeUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
// Reject control chars that could smuggle a scheme.
$stripped = preg_replace('/[\x00-\x20]+/', '', $url);
if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $stripped, $m)) {
if (!in_array(strtolower($m[1]), self::ALLOWED_SCHEMES, true)) {
return null;
}
}
// Allow relative URLs and fragments/anchors as-is.
return $url;
}
private static function safeStyle(?string $style): string
{
$out = [];
foreach (explode(';', (string) $style) as $decl) {
if (!str_contains($decl, ':')) {
continue;
}
[$prop, $val] = array_map('trim', explode(':', $decl, 2));
$prop = strtolower($prop);
$val = strtolower($val);
if ($prop === 'text-align' && in_array($val, ['left', 'right', 'center', 'justify'], true)) {
$out[] = "text-align:{$val}";
}
}
return implode(';', $out);
}
private static function tidy(string $html): string
{
// DOMDocument can emit empty paragraphs/divs from editor churn.
$html = preg_replace('#<(p|div)>(\s|&nbsp;|<br\s*/?>)*</\1>#i', '', $html);
return trim((string) $html);
}
}

View File

@ -1,109 +0,0 @@
<?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";
}
}

View File

@ -1,144 +0,0 @@
<?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 (&nbsp;, &amp;, 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;
}
}

View File

@ -15,14 +15,6 @@ $app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) $_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 | Bind Important Interfaces

View File

@ -6,15 +6,11 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.1", "php": "^8.1",
"bacon/bacon-qr-code": "^3.1",
"doctrine/dbal": "^3.0",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10", "laravel/framework": "^10.10",
"laravel/sanctum": "^3.3", "laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"p7h/nas-file-manager": "dev-main", "php-ffmpeg/php-ffmpeg": "^1.4"
"php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
@ -66,12 +62,6 @@
"php-http/discovery": true "php-http/discovery": true
} }
}, },
"repositories": [ "minimum-stability": "stable",
{
"type": "vcs",
"url": "git@github.com:itsp7h/File-Structure-package.git"
}
],
"minimum-stability": "dev",
"prefer-stable": true "prefer-stable": true
} }

683
composer.lock generated
View File

@ -4,63 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "40fa327b55e9b6fafab4b2da3f763724", "content-hash": "1e4c8a43b8df70dffbd0107a8308b2ec",
"packages": [ "packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-04-05T21:06:35+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.12.3", "version": "0.12.3",
@ -190,56 +135,6 @@
], ],
"time": "2023-12-11T17:09:12+00:00" "time": "2023-12-11T17:09:12+00:00"
}, },
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@ -315,259 +210,6 @@
}, },
"time": "2024-07-08T12:26:09+00:00" "time": "2024-07-08T12:26:09+00:00"
}, },
{
"name": "doctrine/dbal",
"version": "3.10.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/cache": "< 1.11"
},
"require-dev": {
"doctrine/cache": "^1.11|^2.0",
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.34",
"slevomat/coding-standard": "8.27.1",
"squizlabs/php_codesniffer": "4.0.1",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"bin": [
"bin/doctrine-dbal"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2026-02-24T08:03:57+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/common": "<2.9"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/phpstan": "^2.1.32",
"phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
},
{
"name": "Johannes Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com"
}
],
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
"keywords": [
"event",
"event dispatcher",
"event manager",
"event system",
"events"
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
"source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
"type": "tidelift"
}
],
"time": "2026-01-29T07:11:08+00:00"
},
{ {
"name": "doctrine/inflector", "name": "doctrine/inflector",
"version": "2.1.0", "version": "2.1.0",
@ -2802,128 +2444,6 @@
], ],
"time": "2024-11-21T10:36:35+00:00" "time": "2024-11-21T10:36:35+00:00"
}, },
{
"name": "p7h/nas-file-manager",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/itsp7h/File-Structure-package.git",
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1"
},
"require-dev": {
"composer/composer": "^2.0"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"P7H\\NasFileManager\\NasFileManagerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"P7H\\NasFileManager\\": "src/"
}
},
"scripts": {
"post-install-cmd": [
"P7H\\NasFileManager\\Installer::install"
],
"post-update-cmd": [
"P7H\\NasFileManager\\Installer::install"
]
},
"license": [
"MIT"
],
"description": "NAS folder-structure schema viewer and live file manager for Laravel",
"support": {
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
},
"time": "2026-05-14T12:07:04+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{ {
"name": "php-ffmpeg/php-ffmpeg", "name": "php-ffmpeg/php-ffmpeg",
"version": "v1.4.0", "version": "v1.4.0",
@ -3088,201 +2608,6 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "pragmarx/google2fa",
"version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
},
"time": "2024-09-05T11:56:40+00:00"
},
{
"name": "pragmarx/google2fa-laravel",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-laravel.git",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c",
"shasum": ""
},
"require": {
"laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": ">=7.0",
"pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*",
"phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12"
},
"suggest": {
"bacon/bacon-qr-code": "Required to generate inline QR Codes.",
"pragmarx/recovery": "Generate recovery codes."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Google2FA": "PragmaRX\\Google2FALaravel\\Facade"
},
"providers": [
"PragmaRX\\Google2FALaravel\\ServiceProvider"
]
},
"component": "package",
"frameworks": [
"Laravel"
],
"branch-alias": {
"dev-master": "0.2-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FALaravel\\": "src/",
"PragmaRX\\Google2FALaravel\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"Authentication",
"Two Factor Authentication",
"google2fa",
"laravel"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-laravel/issues",
"source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1"
},
"time": "2026-03-17T20:54:53+00:00"
},
{
"name": "pragmarx/google2fa-qrcode",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-qrcode.git",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"shasum": ""
},
"require": {
"php": ">=7.1",
"pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0",
"khanamiryan/qrcode-detector-decoder": "^1.0",
"phpunit/phpunit": "~4|~5|~6|~7|~8|~9"
},
"suggest": {
"bacon/bacon-qr-code": "For QR Code generation, requires imagick",
"chillerlan/php-qrcode": "For QR Code generation"
},
"type": "library",
"extra": {
"component": "package",
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FAQRCode\\": "src/",
"PragmaRX\\Google2FAQRCode\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "QR Code package for Google2FA",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa",
"qr code",
"qrcode"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues",
"source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1"
},
"time": "2025-09-19T23:02:26+00:00"
},
{ {
"name": "psr/cache", "name": "psr/cache",
"version": "3.0.0", "version": "3.0.0",
@ -9374,10 +8699,8 @@
} }
], ],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "stable",
"stability-flags": { "stability-flags": [],
"p7h/nas-file-manager": 20
},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {

View File

@ -16,7 +16,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'TAKEONE'), 'name' => env('APP_NAME', 'Laravel'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -57,7 +57,7 @@ return [
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('APP_URL'), 'asset_url' => env('ASSET_URL'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -70,7 +70,7 @@ return [
| |
*/ */
'timezone' => 'Asia/Bahrain', 'timezone' => 'UTC',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -1,46 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| FFmpeg Binaries
|--------------------------------------------------------------------------
*/
'ffmpeg' => '/usr/lib/jellyfin-ffmpeg/ffmpeg',
'ffprobe' => '/usr/lib/jellyfin-ffmpeg/ffprobe',
'timeout' => 3600,
'thread_number' => 0,
// auto-detect cores
/*
|--------------------------------------------------------------------------
| FFmpeg default options
|--------------------------------------------------------------------------
*/
'defaults' => [
'video' => [
'-c:v h264_nvenc',
'-preset p4', // medium quality/speed
'-rc vbr',
'-cq 23', // CRF 23: visually lossless
'-profile:v high',
'-pix_fmt yuv420p',
],
'audio' => [
'-c:a aac',
'-b:a 192k',
],
],
/*
|--------------------------------------------------------------------------
| GPU Settings
|--------------------------------------------------------------------------
*/
'gpu' => [
'encoder' => 'h264_nvenc', // h264_nvenc or hevc_nvenc
'device' => 0, // GPU 0 (RTX 3060 #1)
'hwaccel' => 'cuda',
],
];

View File

@ -118,12 +118,6 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'orphaned-videos' => [
'driver' => 'single',
'path' => storage_path('logs/orphaned-videos.log'),
'level' => 'info',
],
'null' => [ 'null' => [
'driver' => 'monolog', 'driver' => 'monolog',
'handler' => NullHandler::class, 'handler' => NullHandler::class,

View File

@ -1,80 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| NAS Connection
|--------------------------------------------------------------------------
| Protocol: sftp | ftp | ftps | smb
*/
'connection' => [
'protocol' => env('NAS_PROTOCOL', 'sftp'),
'host' => env('NAS_HOST', ''),
'port' => (int) env('NAS_PORT', 22),
'username' => env('NAS_USERNAME', ''),
'password' => env('NAS_PASSWORD', ''),
'path' => env('NAS_PATH', '/media'),
'smb_share' => env('NAS_SMB_SHARE', ''),
'smb_domain' => env('NAS_SMB_DOMAIN', ''),
],
/*
|--------------------------------------------------------------------------
| Routing
|--------------------------------------------------------------------------
*/
'route_prefix' => env('NAS_FM_ROUTE_PREFIX', 'nas-file-manager'),
'middleware' => ['web', 'auth', 'super_admin'],
/*
|--------------------------------------------------------------------------
| Authorization
|--------------------------------------------------------------------------
| Gate or permission name that controls create / rename / delete actions.
| Set to null to allow any authenticated user.
| Example: 'edit-nas' (checked with Gate::allows())
*/
'edit_gate' => null,
/*
|--------------------------------------------------------------------------
| Folder Schema
|--------------------------------------------------------------------------
| Static schema shown in the "Schema" tab.
| Each node: depth (int), label (string), path (string),
| parent_path (?string), is_template (bool), can_edit (bool)
|
| You can also pass $nodes dynamically to the Blade component:
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
*/
'schema' => [
// ── users ─────────────────────────────────────────────────────────────
['depth' => 0, 'label' => 'users', 'path' => 'users', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
['depth' => 1, 'label' => '{username}', 'path' => 'users/{username}', 'parent_path' => 'users', 'is_template' => true, 'can_edit' => false],
// ── profile ───────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'profile', 'path' => 'u/profile', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'avatar.webp', 'path' => 'profile/avatar.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'cover.webp', 'path' => 'profile/cover.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
// ── videos ────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'videos', 'path' => 'u/videos', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{video-slug}', 'path' => 'videos/{video-slug}', 'parent_path' => 'u/videos', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{video-slug}.{ext}', 'path' => 'vid/file', 'parent_path' => 'videos/{video-slug}', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => 'thumb.webp', 'path' => 'vid/thumb.webp', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'meta.json', 'path' => 'vid/meta.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'view-log.json', 'path' => 'vid/view-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'edit-log.json', 'path' => 'vid/edit-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'slides', 'path' => 'vid/slides', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 5, 'label' => '{position}.{ext}','path' => 'slide/file', 'parent_path' => 'vid/slides', 'is_template' => true, 'can_edit' => false],
// ── posts ─────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'posts', 'path' => 'u/posts', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{post_id}', 'path' => 'posts/{post_id}', 'parent_path' => 'u/posts', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{n}.{ext}', 'path' => 'post/image', 'parent_path' => 'posts/{post_id}', 'is_template' => true, 'can_edit' => false],
],
];

View File

@ -1,48 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('bio')->nullable()->after('avatar');
$table->string('website')->nullable()->after('bio');
$table->string('twitter')->nullable()->after('website');
$table->string('instagram')->nullable()->after('twitter');
$table->string('facebook')->nullable()->after('instagram');
$table->string('youtube')->nullable()->after('facebook');
$table->string('linkedin')->nullable()->after('youtube');
$table->string('tiktok')->nullable()->after('linkedin');
$table->date('birthday')->nullable()->after('tiktok');
$table->string('location')->nullable()->after('birthday');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'bio',
'website',
'twitter',
'instagram',
'facebook',
'youtube',
'linkedin',
'tiktok',
'birthday',
'location'
]);
});
}
};

View File

@ -1,23 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('videos', function (Blueprint $table) {
$table->boolean('is_shorts')->default(false)->after('type');
});
}
public function down()
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('is_shorts');
});
}
};

View File

@ -1,29 +0,0 @@
<?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('playlists', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->string('thumbnail')->nullable();
$table->enum('visibility', ['public', 'private'])->default('private');
$table->boolean('is_default')->default(false); // For "Watch Later" default playlist
$table->timestamps();
$table->index(['user_id', 'visibility']);
});
}
public function down(): void
{
Schema::dropIfExists('playlists');
}
};

View File

@ -1,33 +0,0 @@
<?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('playlist_videos', function (Blueprint $table) {
$table->id();
$table->foreignId('playlist_id')->constrained()->onDelete('cascade');
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->integer('position')->default(0); // For ordering videos in playlist
$table->timestamp('added_at')->useCurrent();
// Watch progress - remember where user left off
$table->integer('watched_seconds')->default(0);
$table->boolean('watched')->default(false);
$table->timestamp('last_watched_at')->nullable();
$table->timestamps();
$table->unique(['playlist_id', 'video_id']);
$table->index(['playlist_id', 'position']);
$table->index(['video_id', 'last_watched_at']);
});
}
public function down(): void
{
Schema::dropIfExists('playlist_videos');
}
};

View File

@ -1,26 +0,0 @@
<?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('match_rounds', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->integer('round_number');
$table->string('name')->default('ROUND');
$table->timestamps();
$table->unique(['video_id', 'round_number']);
});
}
public function down(): void
{
Schema::dropIfExists('match_rounds');
}
};

View File

@ -1,30 +0,0 @@
<?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('match_points', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->foreignId('match_round_id')->constrained('match_rounds')->onDelete('cascade');
$table->integer('timestamp_seconds');
$table->string('action');
$table->integer('points');
$table->string('competitor'); // 'blue' or 'red'
$table->string('notes')->nullable();
$table->integer('score_blue')->default(0);
$table->integer('score_red')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('match_points');
}
};

Some files were not shown because too many files have changed in this diff Show More