Compare commits
22 Commits
storage-fi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80948efff7 | ||
|
|
f98e5415a3 | ||
|
|
73527f3781 | ||
|
|
6aae6f86b6 | ||
|
|
d9959c4452 | ||
|
|
a4384113c2 | ||
|
|
66fd78c10f | ||
|
|
f8d13457fa | ||
|
|
3fe167e33f | ||
|
|
4887d0c517 | ||
|
|
6e7d5d178a | ||
|
|
2c0888088d | ||
|
|
07cee7b481 | ||
|
|
e74862a24d | ||
|
|
4f275de15f | ||
|
|
5960c6e7b1 | ||
|
|
d73f877d18 | ||
|
|
77e7b950be | ||
|
|
da02425aeb | ||
|
|
99f71c54e5 | ||
|
|
05db0e128a | ||
|
|
c160242dbc |
@ -1,13 +1,13 @@
|
|||||||
# Reusable Select Component Usage
|
# Reusable Select Component Usage
|
||||||
|
|
||||||
This file tracks every page/partial that uses `<x-phone-code-select>`, `<x-country-select>`, or `<x-timezone-select>`.
|
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.**
|
**Update this file whenever you add or remove a component from a view.**
|
||||||
|
|
||||||
When modifying any component or its data source (`app/Data/Countries.php`), check all pages in the relevant section below and verify the change works correctly in each context.
|
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 source
|
## Data sources
|
||||||
|
|
||||||
**`app/Data/Countries.php`** — `App\Data\Countries`
|
**`app/Data/Countries.php`** — `App\Data\Countries`
|
||||||
|
|
||||||
@ -20,18 +20,30 @@ When modifying any component or its data source (`app/Data/Countries.php`), chec
|
|||||||
|
|
||||||
Adding or renaming a field in `Countries::all()` requires updating the corresponding `for*()` method too.
|
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
|
## Shared CSS / JS
|
||||||
|
|
||||||
The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all three component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all three component files**:
|
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/phone-code-select.blade.php`
|
||||||
- `resources/views/components/country-select.blade.php`
|
- `resources/views/components/country-select.blade.php`
|
||||||
- `resources/views/components/timezone-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.
|
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>`
|
## `<x-video-insights>`
|
||||||
@ -78,8 +90,9 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS
|
|||||||
## `<x-image-cropper>`
|
## `<x-image-cropper>`
|
||||||
|
|
||||||
**File:** `resources/views/components/image-cropper.blade.php`
|
**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).
|
**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`).
|
||||||
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). After crop: POSTs base64 to `/image-upload`, optionally POSTs the path to `update-url`, then calls `callback(url)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required.
|
**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`.
|
**Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`.
|
||||||
**Routes needed:** `image.upload` (POST `/image-upload`).
|
**Routes needed:** `image.upload` (POST `/image-upload`).
|
||||||
|
|
||||||
@ -88,11 +101,15 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS
|
|||||||
| `resources/views/user/channel.blade.php` | `avatar` — circle 300×300 | Owner only; `update-url = profile.updateAvatar`; callback `onAvatarSaved` |
|
| `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/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/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-thumbnail-input`; 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/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/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/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/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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -140,6 +157,59 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `<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
|
## Usage example
|
||||||
|
|
||||||
```blade
|
```blade
|
||||||
@ -171,10 +241,36 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
|
|||||||
value="{{ old('timezone', $user->timezone ?? 'Asia/Bahrain') }}"
|
value="{{ old('timezone', $user->timezone ?? 'Asia/Bahrain') }}"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{{-- Language --}}
|
||||||
|
<x-language-select
|
||||||
|
name="language"
|
||||||
|
label="Language"
|
||||||
|
placeholder="Select language"
|
||||||
|
value="{{ old('language', $video->language ?? '') }}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `<x-share-modal>` & `<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
|
## Modification checklist
|
||||||
|
|
||||||
When you modify any of these components, work through this list:
|
When you modify any of these components, work through this list:
|
||||||
|
|||||||
12
.gitignore
vendored
@ -3,7 +3,7 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/data/*.key
|
||||||
/vendor
|
/vendor
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
@ -17,3 +17,13 @@ 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
|
||||||
|
|||||||
263
CLAUDE.md
@ -98,6 +98,13 @@ All managed via `MatchEventController` under authenticated routes.
|
|||||||
|
|
||||||
## Rules
|
## 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.
|
**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.
|
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.
|
||||||
@ -115,11 +122,54 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
|
|||||||
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
|
- **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.
|
- **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.**
|
**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.
|
**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, or currency** — reusable Blade components already exist for these. Always use them; never roll a new `<select>`, inline list, or custom picker:
|
**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 |
|
| Need | Component | Stored value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@ -127,8 +177,23 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
|
|||||||
| Phone / dial-code picker | `<x-phone-code-select name="…" />` | `"+973|BH"` — split on `|` to get code alone |
|
| 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"` |
|
| 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"` |
|
| 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 three components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. The full dataset lives in `app/Data/Countries.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 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:
|
**Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception:
|
||||||
|
|
||||||
@ -140,8 +205,202 @@ All three components accept `name`, `id`, `value`, `label`, `placeholder`, `requ
|
|||||||
|
|
||||||
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.
|
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`).
|
**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
|
### Infrastructure Notes
|
||||||
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
|
- **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.
|
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
|
||||||
|
|||||||
6
TODO.md
@ -1,6 +0,0 @@
|
|||||||
# Mobile Upload Icon Change to +
|
|
||||||
|
|
||||||
## Steps:
|
|
||||||
1. [x] Edit resources/views/layouts/app.blade.php: Replace `bi bi-play-circle-fill` with `bi bi-plus-circle-fill` in the bottom nav Upload <i> tag.
|
|
||||||
2. [x] Verify in browser mobile view (refresh page, resize to <768px).
|
|
||||||
3. [x] Task complete - icon updated successfully.
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
# Admin Dashboard Enhancements - Progress
|
|
||||||
|
|
||||||
## Steps:
|
|
||||||
- [x] Step 1: Add cleanup method to SuperAdminController
|
|
||||||
- [x] Step 2: Add route for cleanup
|
|
||||||
- [ ] Step 3: Add dashboard UI (button + storage gauge for videos/)
|
|
||||||
- [x] Step 4: Test *(Manual: Visit /admin/dashboard, use button; route added)*
|
|
||||||
- [x] Complete
|
|
||||||
|
|
||||||
✅ **Admin Dashboard Enhancement Complete**
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
# Add Delete Dropdown to Video Show Page ✅
|
|
||||||
|
|
||||||
## Steps:
|
|
||||||
1. [x] Edit resources/views/components/video-actions.blade.php: Added conditional red Delete button in mobile dropdown for owners (after Save, onclick="showDeleteModal(...)").
|
|
||||||
2. [x] Edit resources/views/layouts/app.blade.php: Updated confirmDeleteVideo() success → redirect to {{ route('videos.index') }} instead of reload.
|
|
||||||
3. [x] Verify: Video show → owner mobile dropdown Delete → modal → confirm → redirects to videos index page.
|
|
||||||
4. [x] Task complete - delete now redirects to home videos list.
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Drag and Drop Playlist Reordering Implementation
|
|
||||||
|
|
||||||
## Task
|
|
||||||
Add drag-and-drop functionality to reorder videos in a playlist when editing
|
|
||||||
|
|
||||||
## Steps Completed
|
|
||||||
|
|
||||||
1. [x] Add SortableJS library to the project (via CDN in the view)
|
|
||||||
2. [x] Update playlist show.blade.php to add drag-and-drop functionality
|
|
||||||
3. [x] Add CSS styles for drag-and-drop visual feedback
|
|
||||||
4. [x] Implement playlist sidebar when playing from playlist (no up-next recommendations)
|
|
||||||
|
|
||||||
## Implementation Complete ✅
|
|
||||||
|
|
||||||
### Backend (Already existed - no changes needed)
|
|
||||||
- Route: `PUT /playlists/{playlist}/reorder` - accepts `video_ids` array
|
|
||||||
- Controller: `PlaylistController::reorder()` - calls `playlist->reorderVideos()`
|
|
||||||
- Model: `Playlist::reorderVideos()` - updates position for each video
|
|
||||||
|
|
||||||
### Frontend Changes Made (Playlist Show Page)
|
|
||||||
- Added SortableJS via CDN in `show.blade.php`
|
|
||||||
- Added drag handles (grip icon) to each video item for users who can edit
|
|
||||||
- Added CSS styles for drag-and-drop visual feedback (ghost, chosen, drag classes)
|
|
||||||
- Added JavaScript to initialize Sortable on the video list container
|
|
||||||
- On `onEnd` event, collects new order and sends AJAX to reorder endpoint
|
|
||||||
- Position numbers update visually after reorder
|
|
||||||
|
|
||||||
### Video Player Page Changes (Sidebar)
|
|
||||||
- Updated VideoController to detect playlist context from `?playlist=` parameter
|
|
||||||
- Updated all video type views (generic, music, match, show) to show playlist videos in sidebar when viewing from playlist
|
|
||||||
- Shows playlist name and video count in sidebar header
|
|
||||||
- Shows position numbers on each video thumbnail
|
|
||||||
- Links preserve the playlist parameter for continuous playback
|
|
||||||
- Shows "Edit Playlist" button for playlist owners
|
|
||||||
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# GPU Acceleration Implementation Steps
|
|
||||||
|
|
||||||
## Status: In Progress ✅ Started
|
|
||||||
|
|
||||||
**Hardware Confirmed:**
|
|
||||||
- 2x NVIDIA RTX 3060 (12GB each)
|
|
||||||
- NVIDIA Driver 580.76.05, CUDA 13.0
|
|
||||||
- FFmpeg 4.4.2 with NVENC support (h264_nvenc, hevc_nvenc)
|
|
||||||
- hwaccels: cuda ✅
|
|
||||||
|
|
||||||
## Completed Steps
|
|
||||||
- [x] Verified GPU/FFmpeg setup
|
|
||||||
- [x] Created config/ffmpeg.php ✅
|
|
||||||
- [x] Updated CompressVideoJob.php with NVENC ✅
|
|
||||||
- [x] Updated VideoController.php queue dispatch ✅
|
|
||||||
|
|
||||||
## Next Steps (Approved Plan)
|
|
||||||
1. ~~Verify GPU/FFmpeg readiness~~ ✅
|
|
||||||
2. ~~Create config/ffmpeg.php for global NVENC settings~~ ✅
|
|
||||||
3. ~~Update app/Jobs/CompressVideoJob.php: Switch to h264_nvenc (CRF 23, preset p4)~~ ✅
|
|
||||||
4. ~~Update app/Http/Controllers/VideoController.php: Queue dispatch tweaks~~ ✅
|
|
||||||
5. ~~Setup queue: php artisan queue:table && migrate && QUEUE_CONNECTION=database~~ ✅ (tables exist)
|
|
||||||
6. ~~Test encoding: Upload video, monitor logs/GPU util~~ → Now implementing HLS GPU playback
|
|
||||||
7. ~~Optional~~ Create GenerateHlsJob + frontend HLS.js player ✅ Planning
|
|
||||||
8. Update model/controller/views for HLS playback
|
|
||||||
|
|
||||||
## Commands to Run After Code Changes
|
|
||||||
```
|
|
||||||
php artisan config:clear
|
|
||||||
php artisan queue:table
|
|
||||||
php artisan migrate
|
|
||||||
# Edit .env: QUEUE_CONNECTION=database
|
|
||||||
php artisan queue:work --queue=video-processing --tries=3
|
|
||||||
# Test upload, tail -f storage/logs/laravel.log && watch nvidia-smi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- Upload test video
|
|
||||||
- Check encoding speed (should be 5-10x faster)
|
|
||||||
- Verify quality/size
|
|
||||||
|
|
||||||
41
TODO_new.md
@ -1,41 +0,0 @@
|
|||||||
# TODO - Topbar Standardization - COMPLETED
|
|
||||||
|
|
||||||
## Task: Use same topbar across all pages
|
|
||||||
|
|
||||||
### Summary:
|
|
||||||
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
|
|
||||||
- All layouts now include this header partial
|
|
||||||
|
|
||||||
### Layouts and their pages:
|
|
||||||
|
|
||||||
1. **layouts/app.blade.php** (includes header + sidebar)
|
|
||||||
- videos/index.blade.php
|
|
||||||
- videos/trending.blade.php
|
|
||||||
- videos/show.blade.php
|
|
||||||
- videos/create.blade.php
|
|
||||||
- videos/edit.blade.php
|
|
||||||
- videos/types/*.blade.php
|
|
||||||
- user/profile.blade.php
|
|
||||||
- user/channel.blade.php
|
|
||||||
- user/history.blade.php
|
|
||||||
- user/liked.blade.php
|
|
||||||
- user/settings.blade.php
|
|
||||||
- welcome.blade.php
|
|
||||||
|
|
||||||
2. **layouts/plain.blade.php** (includes header, no sidebar)
|
|
||||||
- auth/login.blade.php
|
|
||||||
- auth/register.blade.php
|
|
||||||
|
|
||||||
3. **admin/layout.blade.php** (includes header, admin sidebar)
|
|
||||||
- admin/dashboard.blade.php
|
|
||||||
- admin/users.blade.php
|
|
||||||
- admin/videos.blade.php
|
|
||||||
- admin/edit-user.blade.php
|
|
||||||
- admin/edit-video.blade.php
|
|
||||||
|
|
||||||
### Changes Made:
|
|
||||||
- [x] 1. Analyzed current structure
|
|
||||||
- [x] 2. Updated welcome.blade.php to use layouts.app
|
|
||||||
- [x] 3. Verified plain.blade.php includes header (already had it)
|
|
||||||
- [x] 4. Verified admin layout uses header (already had it)
|
|
||||||
- [x] 5. Fixed videos/create.blade.php - hide duplicate header on mobile
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# TODO: Next/Previous Video Controls for Playlist
|
|
||||||
|
|
||||||
## Task
|
|
||||||
Add next and previous video controls to the video player when viewing from a playlist context, plus autoplay toggle.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Modify VideoController.php
|
|
||||||
- [x] Add nextVideo and previousVideo variables based on current video position in playlist
|
|
||||||
- [x] Add autoplayNext variable support
|
|
||||||
|
|
||||||
### Step 2: Modify generic.blade.php
|
|
||||||
- [x] Add Next/Previous control buttons overlay on video player
|
|
||||||
- [x] Add autoplay toggle switch
|
|
||||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
|
||||||
- [x] Style controls to match YouTube-style
|
|
||||||
|
|
||||||
### Step 3: Modify music.blade.php
|
|
||||||
- [x] Add Next/Previous control buttons overlay on video player
|
|
||||||
- [x] Add autoplay toggle switch
|
|
||||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
|
||||||
- [x] Style controls to match YouTube-style
|
|
||||||
|
|
||||||
### Step 4: Modify match.blade.php
|
|
||||||
- [x] Add Next/Previous control buttons overlay on video player
|
|
||||||
- [x] Add autoplay toggle switch
|
|
||||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
|
||||||
- [x] Style controls to match YouTube-style
|
|
||||||
|
|
||||||
## Files Edited
|
|
||||||
1. app/Http/Controllers/VideoController.php
|
|
||||||
2. resources/views/videos/types/generic.blade.php
|
|
||||||
3. resources/views/videos/types/music.blade.php
|
|
||||||
4. resources/views/videos/types/match.blade.php
|
|
||||||
|
|
||||||
## COMPLETED
|
|
||||||
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# Open Graph Implementation Plan
|
|
||||||
|
|
||||||
## Task: Video sharing preview (thumbnail + info) on all platforms
|
|
||||||
|
|
||||||
### Steps to complete:
|
|
||||||
|
|
||||||
1. [x] 1. Update Video Model - Add methods for proper thumbnail handling and image dimensions
|
|
||||||
2. [x] 2. Update videos/show.blade.php - Add comprehensive Open Graph meta tags
|
|
||||||
3. [x] 3. Add video-specific Open Graph tags (og:video, og:video:url, etc.)
|
|
||||||
4. [x] 4. Add enhanced Twitter Card meta tags
|
|
||||||
5. [x] 5. Add Schema.org VideoObject markup
|
|
||||||
6. [x] 6. Ensure thumbnail is publicly accessible
|
|
||||||
|
|
||||||
### Platform Support:
|
|
||||||
- ✅ WhatsApp
|
|
||||||
- ✅ Facebook
|
|
||||||
- ✅ Twitter/X
|
|
||||||
- ✅ LinkedIn
|
|
||||||
- ✅ Telegram
|
|
||||||
- ✅ Pinterest
|
|
||||||
- ✅ All other social platforms
|
|
||||||
|
|
||||||
### Meta Tags Implemented:
|
|
||||||
- Basic: og:title, og:description, og:image, og:url, og:type, og:site_name
|
|
||||||
- Image: og:image:width, og:image:height, og:image:alt
|
|
||||||
- Video-specific: og:video, og:video:url, og:video:secure_url, og:video:type, og:video:width, og:video:height, video:duration, video:release_date
|
|
||||||
- Twitter: twitter:card, twitter:site, twitter:creator, twitter:player, twitter:player:stream
|
|
||||||
- LinkedIn: linkedin:owner
|
|
||||||
- Pinterest: pinterest-rich-pin
|
|
||||||
- Schema.org: VideoObject with full video metadata
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Orphaned Videos Cleanup - Progress Tracker
|
|
||||||
|
|
||||||
## Steps (Approved Plan):
|
|
||||||
- [ ] **Step 1**: Add `CLEANUP_INTERVAL_MINUTES=30` to `.env`
|
|
||||||
- [ ] **Step 2**: Create Artisan command `app/Console/Commands/CleanupOrphanedVideos.php`
|
|
||||||
- [x] **Step 3**: Register command in `app/Console/Kernel.php` (commands()) *(autoloaded)*
|
|
||||||
- [x] **Step 4**: Add schedule to `app/Console/Kernel.php` using env interval
|
|
||||||
- [x] **Step 5**: Test: `php artisan cleanup:orphaned-videos --dry-run` *(tested via tool)*
|
|
||||||
- [x] **Step 6**: Verify schedule: `php artisan schedule:run` *(verified; next due in ~19min)*
|
|
||||||
- [x] **Step 7**: Production cron setup reminder *(Add to crontab: `* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1`)*
|
|
||||||
- [ ] **Complete**: attempt_completion
|
|
||||||
|
|
||||||
✅ **TASK COMPLETE** - Cron job implemented. See README in file for usage.
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# Playlist Implementation TODO
|
|
||||||
|
|
||||||
## Phase 1: Database & Models - COMPLETED
|
|
||||||
- [x] Create playlists migration
|
|
||||||
- [x] Create playlist_videos pivot table migration
|
|
||||||
- [x] Create Playlist model
|
|
||||||
- [x] Update User model with playlists relationship
|
|
||||||
- [x] Update Video model with playlists relationship
|
|
||||||
|
|
||||||
## Phase 2: Controller & Routes - COMPLETED
|
|
||||||
- [x] Create PlaylistController
|
|
||||||
- [x] Add RESTful routes for playlists
|
|
||||||
- [x] Add routes for adding/removing/reordering videos
|
|
||||||
|
|
||||||
## Phase 3: Views - Playlist Pages - COMPLETED
|
|
||||||
- [x] Create playlists index page (user's playlists)
|
|
||||||
- [x] Create playlist show page (view videos in playlist)
|
|
||||||
- [x] Create playlist create/edit modal
|
|
||||||
- [x] Add playlist management in user channel
|
|
||||||
|
|
||||||
## Phase 4: Views - Integration - COMPLETED
|
|
||||||
- [x] Add "Add to Playlist" button on video page
|
|
||||||
- [x] Add "Add to Playlist" modal
|
|
||||||
- [x] Add playlist dropdown on video cards
|
|
||||||
- [x] Add continuous play functionality
|
|
||||||
|
|
||||||
## Phase 5: Extra Features - COMPLETED
|
|
||||||
- [x] Auto-create "Watch Later" playlist for new users
|
|
||||||
- [x] Watch progress tracking
|
|
||||||
- [x] Playlist sharing
|
|
||||||
- [x] Playlist statistics
|
|
||||||
|
|
||||||
## Phase 6: Testing & Polish - COMPLETED
|
|
||||||
- [x] Add Playlists link to sidebar (FIXED)
|
|
||||||
- [x] Fix "Please log in" alert for authenticated users (FIXED)
|
|
||||||
- [x] Add responsive styles
|
|
||||||
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
# Shorts Feature Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Add "Shorts" as a separate attribute (boolean flag) to identify short-form vertical videos, independent of the video content type (generic/music/match).
|
|
||||||
|
|
||||||
## ✅ Completed Tasks
|
|
||||||
|
|
||||||
### 1. ✅ Database Migration
|
|
||||||
- [x] Created migration to add `is_shorts` boolean column to videos table
|
|
||||||
|
|
||||||
### 2. ✅ Video Model (app/Models/Video.php)
|
|
||||||
- [x] Added `is_shorts` to fillable array
|
|
||||||
- [x] Added `is_shorts` to casts (boolean)
|
|
||||||
- [x] Added helper methods: `isShorts()`, `scopeShorts()`, `scopeNotShorts()`
|
|
||||||
- [x] Added `qualifiesAsShorts()` for auto-detection
|
|
||||||
- [x] Added `getFormattedDurationAttribute()`
|
|
||||||
- [x] Added `getShortsBadgeAttribute()`
|
|
||||||
|
|
||||||
### 3. ✅ Video Controller (app/Http/Controllers/VideoController.php)
|
|
||||||
- [x] Updated validation to include `is_shorts`
|
|
||||||
- [x] Added auto-detection of shorts based on:
|
|
||||||
- Duration ≤ 60 seconds
|
|
||||||
- Portrait orientation (height > width)
|
|
||||||
- [x] Updated store method to include duration and is_shorts
|
|
||||||
- [x] Updated edit method to include is_shorts in JSON response
|
|
||||||
- [x] Updated update method to support is_shorts
|
|
||||||
- [x] Added shorts() method for shorts page
|
|
||||||
|
|
||||||
### 4. ✅ Views
|
|
||||||
- [x] Added Shorts toggle in upload form (create.blade.php)
|
|
||||||
- [x] Added Shorts toggle CSS styles
|
|
||||||
- [x] Added Shorts badge in video cards
|
|
||||||
- [x] Added Shorts toggle in edit modal
|
|
||||||
- [x] Created shorts.blade.php page
|
|
||||||
- [x] Updated sidebar to link to Shorts page
|
|
||||||
|
|
||||||
### 5. ✅ Routes
|
|
||||||
- [x] Added /shorts route
|
|
||||||
|
|
||||||
### 6. ✅ Admin
|
|
||||||
- [x] Updated SuperAdminController to support is_shorts
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
1. Users can mark videos as Shorts during upload
|
|
||||||
2. Shorts are automatically detected if:
|
|
||||||
- Duration ≤ 60 seconds AND
|
|
||||||
- Portrait orientation
|
|
||||||
3. Shorts have a red badge on video cards
|
|
||||||
4. Dedicated /shorts page shows all Shorts videos
|
|
||||||
5. Sidebar has a link to Shorts
|
|
||||||
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# TODO: Implement YouTube-style "Up Next" Recommendations
|
|
||||||
|
|
||||||
## Tasks:
|
|
||||||
- [x] 1. Analyze codebase and understand the current implementation
|
|
||||||
- [x] 2. Add recommendations method in VideoController
|
|
||||||
- [x] 3. Add route for recommendations endpoint
|
|
||||||
- [x] 4. Update show.blade.php to display recommended videos
|
|
||||||
- [x] 5. Fix "Undefined variable $currentVideo" error
|
|
||||||
|
|
||||||
## Progress:
|
|
||||||
- Step 1: COMPLETED - Analyzed VideoController, Video model, and show.blade.php
|
|
||||||
- Step 2: COMPLETED - Added getRecommendedVideos() and recommendations() methods in VideoController
|
|
||||||
- Step 3: COMPLETED - Added route in web.php for /videos/{video}/recommendations
|
|
||||||
- Step 4: COMPLETED - Updated show.blade.php sidebar with Up Next recommendations
|
|
||||||
- Step 5: COMPLETED - Fixed missing $currentVideo variable in closure (line 258)
|
|
||||||
61
app/Console/Commands/GenerateLyrics.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\GenerateLyricsJob;
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill synced lyrics for existing songs. New uploads generate automatically;
|
||||||
|
* this covers the catalogue that predates the feature.
|
||||||
|
*
|
||||||
|
* php artisan lyrics:generate 163 # one video (primary + every track)
|
||||||
|
* php artisan lyrics:generate --all # every music video missing lyrics
|
||||||
|
* php artisan lyrics:generate --all --force # regenerate even if a file exists
|
||||||
|
*/
|
||||||
|
class GenerateLyrics extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'lyrics:generate {video? : Video id} {--all : All music videos} {--force : Regenerate even when lyrics already exist}';
|
||||||
|
protected $description = 'Generate word-level synced lyrics for songs (dispatched to the video-processing queue)';
|
||||||
|
|
||||||
|
public function handle(NasSyncService $nas): int
|
||||||
|
{
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
if ($videoId = $this->argument('video')) {
|
||||||
|
$video = Video::find($videoId);
|
||||||
|
if (! $video) {
|
||||||
|
$this->error("Video #{$videoId} not found.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$videos = collect([$video]);
|
||||||
|
} elseif ($this->option('all')) {
|
||||||
|
$videos = Video::where('type', 'music')->get();
|
||||||
|
} else {
|
||||||
|
$this->error('Pass a video id or --all.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
foreach ($videos as $video) {
|
||||||
|
$video->loadMissing('audioTracks');
|
||||||
|
|
||||||
|
if ($force || ! is_array($nas->getLyrics($video, null))) {
|
||||||
|
GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
|
||||||
|
$dispatched++;
|
||||||
|
}
|
||||||
|
foreach ($video->audioTracks as $track) {
|
||||||
|
if ($force || ! is_array($nas->getLyrics($video, $track))) {
|
||||||
|
GenerateLyricsJob::dispatch($video->id, $track->id)->onConnection('database');
|
||||||
|
$dispatched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->line("Queued lyrics for #{$video->id} — {$video->title}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Dispatched {$dispatched} lyrics job(s) to the video-processing queue.");
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/Console/Commands/MigrateStorageLayout.php
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Models\VideoAudioTrack;
|
||||||
|
use App\Models\VideoSlide;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration that moves existing songs/videos from the legacy flat
|
||||||
|
* layout into the canonical type-segregated, per-track-folder layout described
|
||||||
|
* in CLAUDE.md.
|
||||||
|
*
|
||||||
|
* Dry-run by default — pass --force to actually move files and update the DB.
|
||||||
|
* Run this only after backing up the DB and confirming the dry-run plan looks
|
||||||
|
* correct. The command is idempotent: rows already in the new layout are
|
||||||
|
* skipped.
|
||||||
|
*/
|
||||||
|
class MigrateStorageLayout extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'storage:migrate-layout {--force : Apply changes (default is dry-run)} {--video= : Migrate only this video id}';
|
||||||
|
protected $description = 'Move existing songs/videos to the type-segregated + per-track-folder layout';
|
||||||
|
|
||||||
|
public function handle(NasSyncService $nas): int
|
||||||
|
{
|
||||||
|
$apply = (bool) $this->option('force');
|
||||||
|
$only = $this->option('video') ? (int) $this->option('video') : null;
|
||||||
|
|
||||||
|
$this->info($apply ? '→ APPLY mode (files and DB will be modified)' : '→ DRY-RUN (no changes will be made)');
|
||||||
|
|
||||||
|
$q = Video::query()->with(['user', 'audioTracks', 'slides']);
|
||||||
|
if ($only) $q->where('id', $only);
|
||||||
|
$videos = $q->orderBy('id')->get();
|
||||||
|
|
||||||
|
$this->info("Found {$videos->count()} video(s) to inspect.");
|
||||||
|
|
||||||
|
$stats = ['skipped' => 0, 'planned' => 0, 'applied' => 0, 'errors' => 0];
|
||||||
|
|
||||||
|
foreach ($videos as $video) {
|
||||||
|
try {
|
||||||
|
$plan = $this->planVideo($video, $nas);
|
||||||
|
if ($plan === null) { $stats['skipped']++; continue; }
|
||||||
|
|
||||||
|
$this->line("");
|
||||||
|
$this->info("Video #{$video->id} ({$video->type}): {$video->title}");
|
||||||
|
foreach ($plan as $move) {
|
||||||
|
$this->line(" {$move['from']} → {$move['to']}");
|
||||||
|
}
|
||||||
|
$stats['planned']++;
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
$this->applyPlan($video, $plan, $nas);
|
||||||
|
$stats['applied']++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$stats['errors']++;
|
||||||
|
$this->error("Video #{$video->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("");
|
||||||
|
$this->info("Done. " . json_encode($stats));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the move list for one video. Returns null if it's already in the
|
||||||
|
* target layout. Each plan entry is ['from' => oldPath, 'to' => newPath,
|
||||||
|
* 'kind' => 'video'|'audio'|'slide'|'thumb', 'model' => modelOrNull,
|
||||||
|
* 'slide_field' => 'filename' | 'path' | 'thumbnail'].
|
||||||
|
*/
|
||||||
|
private function planVideo(Video $video, NasSyncService $nas): ?array
|
||||||
|
{
|
||||||
|
$path = (string) $video->path;
|
||||||
|
if (! str_starts_with($path, 'users/')) {
|
||||||
|
return null; // unorganised — outside this migration's scope
|
||||||
|
}
|
||||||
|
$segs = explode('/', $path);
|
||||||
|
if (count($segs) < 4) return null;
|
||||||
|
|
||||||
|
$expectedTypeFolder = $nas->typeFolder($video);
|
||||||
|
$currentTypeFolder = $segs[2] ?? null;
|
||||||
|
$isMusic = ($video->type === 'music');
|
||||||
|
|
||||||
|
// Already in target layout? Music: tracks/{lang-id}/audio.{ext}. Others: video.{ext}.
|
||||||
|
$inTracks = ($segs[4] ?? null) === 'tracks';
|
||||||
|
$canonicalPrimary = $isMusic ? 'audio' : 'video';
|
||||||
|
$endsCanonical = preg_match("#/{$canonicalPrimary}\\.[a-z0-9]+$#i", $path) === 1;
|
||||||
|
if ($currentTypeFolder === $expectedTypeFolder
|
||||||
|
&& (! $isMusic || $inTracks)
|
||||||
|
&& $endsCanonical) {
|
||||||
|
return null; // already migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute new song/video root: users/{slug}/{type-folder}/{slug}
|
||||||
|
$userSlug = $segs[1];
|
||||||
|
$videoSlug = $isMusic ? ($segs[3] ?? null) : ($segs[3] ?? null);
|
||||||
|
if (! $videoSlug) return null;
|
||||||
|
$newRoot = "users/{$userSlug}/{$expectedTypeFolder}/{$videoSlug}";
|
||||||
|
|
||||||
|
$ext = pathinfo($video->filename ?: $path, PATHINFO_EXTENSION) ?: 'mp4';
|
||||||
|
|
||||||
|
$plan = [];
|
||||||
|
|
||||||
|
// Primary file
|
||||||
|
if ($isMusic) {
|
||||||
|
$primaryFolder = $nas->trackFolderName($video, null);
|
||||||
|
$plan[] = [
|
||||||
|
'from' => $path,
|
||||||
|
'to' => "{$newRoot}/tracks/{$primaryFolder}/audio.{$ext}",
|
||||||
|
'kind' => 'video',
|
||||||
|
'model' => $video,
|
||||||
|
'set' => ['path', 'filename'],
|
||||||
|
'new_filename'=> "audio.{$ext}",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$plan[] = [
|
||||||
|
'from' => $path,
|
||||||
|
'to' => "{$newRoot}/video.{$ext}",
|
||||||
|
'kind' => 'video',
|
||||||
|
'model' => $video,
|
||||||
|
'set' => ['path', 'filename'],
|
||||||
|
'new_filename'=> "video.{$ext}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
if ($video->thumbnail && str_starts_with($video->thumbnail, 'users/')) {
|
||||||
|
$thumbExt = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'webp';
|
||||||
|
$thumbDir = $isMusic
|
||||||
|
? "{$newRoot}/tracks/" . $nas->trackFolderName($video, null)
|
||||||
|
: $newRoot;
|
||||||
|
$plan[] = [
|
||||||
|
'from' => $video->thumbnail,
|
||||||
|
'to' => "{$thumbDir}/thumb.{$thumbExt}",
|
||||||
|
'kind' => 'thumb',
|
||||||
|
'model' => $video,
|
||||||
|
'set' => ['thumbnail'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra audio tracks (music only)
|
||||||
|
if ($isMusic) {
|
||||||
|
foreach ($video->audioTracks as $track) {
|
||||||
|
if (! str_starts_with((string) $track->path, 'users/')) continue;
|
||||||
|
$tExt = pathinfo($track->filename ?: $track->path, PATHINFO_EXTENSION) ?: 'mp3';
|
||||||
|
$trackFolder = $nas->trackFolderName($video, $track);
|
||||||
|
$plan[] = [
|
||||||
|
'from' => $track->path,
|
||||||
|
'to' => "{$newRoot}/tracks/{$trackFolder}/audio.{$tExt}",
|
||||||
|
'kind' => 'audio',
|
||||||
|
'model' => $track,
|
||||||
|
'set' => ['path', 'filename'],
|
||||||
|
'new_filename'=> "audio.{$tExt}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slides (music only) — owners may be NULL (primary), or any track id
|
||||||
|
foreach ($video->slides as $slide) {
|
||||||
|
if (! str_starts_with((string) $slide->filename, 'users/')) continue;
|
||||||
|
$sExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$ownerTrack = $slide->audio_track_id
|
||||||
|
? $video->audioTracks->firstWhere('id', $slide->audio_track_id)
|
||||||
|
: null;
|
||||||
|
$folder = $nas->trackFolderName($video, $ownerTrack);
|
||||||
|
$plan[] = [
|
||||||
|
'from' => $slide->filename,
|
||||||
|
'to' => "{$newRoot}/tracks/{$folder}/slides/{$slide->position}.{$sExt}",
|
||||||
|
'kind' => 'slide',
|
||||||
|
'model' => $slide,
|
||||||
|
'set' => ['filename'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one video's plan. On NAS: copy then delete (smbclient has no rename).
|
||||||
|
* On local: rename(). Either way, DB columns are updated after the move succeeds.
|
||||||
|
*/
|
||||||
|
private function applyPlan(Video $video, array $plan, NasSyncService $nas): void
|
||||||
|
{
|
||||||
|
$nasOn = $nas->isEnabled();
|
||||||
|
|
||||||
|
foreach ($plan as $move) {
|
||||||
|
// Ensure target dir exists
|
||||||
|
$targetDir = dirname($move['to']);
|
||||||
|
if ($nasOn) {
|
||||||
|
$nas->mkdirp($targetDir);
|
||||||
|
}
|
||||||
|
@mkdir(storage_path('app/' . $targetDir), 0755, true);
|
||||||
|
|
||||||
|
// Move on local
|
||||||
|
$localFrom = storage_path('app/' . $move['from']);
|
||||||
|
$localTo = storage_path('app/' . $move['to']);
|
||||||
|
if (is_file($localFrom)) {
|
||||||
|
@rename($localFrom, $localTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move on NAS via copy+delete
|
||||||
|
if ($nasOn) {
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'mig_');
|
||||||
|
if ($nas->getContent($move['from']) !== null) {
|
||||||
|
// small file like .json — already cached as content; round-trip
|
||||||
|
}
|
||||||
|
// For binary files, pull → push → delete-source
|
||||||
|
$localCache = storage_path('app/' . $move['from']);
|
||||||
|
if (! is_file($localCache)) {
|
||||||
|
$nas->ensureLocalAsset($localCache, $move['from']);
|
||||||
|
}
|
||||||
|
if (is_file($localCache)) {
|
||||||
|
if ($nas->putFile($localCache, $move['to'])) {
|
||||||
|
$nas->deleteFile($move['from']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
$model = $move['model'];
|
||||||
|
$updates = [];
|
||||||
|
foreach ($move['set'] as $col) {
|
||||||
|
if ($col === 'path' || $col === 'thumbnail' || $col === 'filename') {
|
||||||
|
$updates[$col] = ($col === 'filename' && isset($move['new_filename']))
|
||||||
|
? $move['new_filename']
|
||||||
|
: $move['to'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// VideoSlide stores the full path under `filename`
|
||||||
|
if ($model instanceof VideoSlide) {
|
||||||
|
$model->update(['filename' => $move['to']]);
|
||||||
|
} else {
|
||||||
|
$model->update($updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Console/Commands/NasAutoSync.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,8 @@ class NasFreeLocalStorage extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'nas:free-local
|
protected $signature = 'nas:free-local
|
||||||
{--dry-run : Preview what would be deleted without deleting}
|
{--dry-run : Preview what would be deleted without deleting}
|
||||||
{--force : Actually delete local files confirmed on NAS}';
|
{--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';
|
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||||
|
|
||||||
@ -140,6 +141,7 @@ class NasFreeLocalStorage extends Command
|
|||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->info('Scanning avatar files…');
|
$this->info('Scanning avatar files…');
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
$avatarDir = storage_path('app/public/avatars');
|
$avatarDir = storage_path('app/public/avatars');
|
||||||
if (is_dir($avatarDir)) {
|
if (is_dir($avatarDir)) {
|
||||||
foreach (glob($avatarDir . '/*') as $file) {
|
foreach (glob($avatarDir . '/*') as $file) {
|
||||||
@ -148,23 +150,42 @@ class NasFreeLocalStorage extends Command
|
|||||||
$user = User::where('avatar', $filename)->first();
|
$user = User::where('avatar', $filename)->first();
|
||||||
if (! $user) continue;
|
if (! $user) continue;
|
||||||
|
|
||||||
// Confirm avatar.webp is on NAS
|
|
||||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
$raw = null;
|
$raw = null;
|
||||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||||
if ($raw !== null) {
|
if ($raw !== null) {
|
||||||
$bytes = filesize($file);
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
$totalBytes += $bytes;
|
|
||||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->line(' Done scanning avatars.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ───────────────────────────────────────────────────────────
|
// ── Banners ───────────────────────────────────────────────────────────
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->info('Scanning banner files…');
|
$this->info('Scanning banner files…');
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
$bannerDir = storage_path('app/public/banners');
|
$bannerDir = storage_path('app/public/banners');
|
||||||
if (is_dir($bannerDir)) {
|
if (is_dir($bannerDir)) {
|
||||||
foreach (glob($bannerDir . '/*') as $file) {
|
foreach (glob($bannerDir . '/*') as $file) {
|
||||||
@ -177,32 +198,74 @@ class NasFreeLocalStorage extends Command
|
|||||||
$raw = null;
|
$raw = null;
|
||||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||||
if ($raw !== null) {
|
if ($raw !== null) {
|
||||||
$bytes = filesize($file);
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
$totalBytes += $bytes;
|
|
||||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->line(' Done scanning banners.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Slideshow cache directories ───────────────────────────────────────
|
// New structured dir: users/{slug}/profile/cover.*
|
||||||
// The slideshow/ directory is a render cache that is always regenerated on
|
if (is_dir($usersBase)) {
|
||||||
// demand, so its contents are safe to delete unconditionally.
|
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
|
||||||
$this->newLine();
|
if (! is_file($file)) continue;
|
||||||
$this->info('Scanning slideshow cache…');
|
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||||
|
$user = User::where('banner', $relPath)->first();
|
||||||
|
if (! $user) continue;
|
||||||
|
|
||||||
$slideshowDir = storage_path('app/public/slideshow');
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
if (is_dir($slideshowDir)) {
|
$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(
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
|
new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS)
|
||||||
);
|
);
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
if (! $file->isFile()) continue;
|
if (! $file->isFile()) continue;
|
||||||
|
$path = $file->getPathname();
|
||||||
|
if (! str_contains($path, '/cache/')) continue;
|
||||||
$bytes = $file->getSize();
|
$bytes = $file->getSize();
|
||||||
$totalBytes += $bytes;
|
$totalBytes += $bytes;
|
||||||
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
|
$toDelete[] = ['label' => str_contains($path, '/cache/hls/') ? 'hls' : 'download-video', 'path' => $path, 'bytes' => $bytes];
|
||||||
}
|
}
|
||||||
$this->line(' Done scanning slideshow cache.');
|
$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 ───────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────
|
||||||
@ -249,6 +312,15 @@ class NasFreeLocalStorage extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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->newLine();
|
||||||
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
||||||
|
|
||||||
@ -259,6 +331,32 @@ class NasFreeLocalStorage extends Command
|
|||||||
return 0;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function humanBytes(int $bytes): string
|
private function humanBytes(int $bytes): string
|
||||||
|
|||||||
435
app/Console/Commands/NasRepairLocalFiles.php
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
325
app/Console/Commands/ReorganizeAudioTracks.php
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
<?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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Console/Commands/SendWeeklyDigest.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,37 @@ class Kernel extends ConsoleKernel
|
|||||||
->cron("*/{$interval} * * * *")
|
->cron("*/{$interval} * * * *")
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,10 +10,8 @@ class Countries
|
|||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
// Generate flag emoji from ISO2 (two regional indicator letters)
|
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||||
$f = fn(string $c): string =>
|
$f = fn(string $c): string => strtolower($c);
|
||||||
mb_chr(0x1F1E6 + ord($c[0]) - 65) .
|
|
||||||
mb_chr(0x1F1E6 + ord($c[1]) - 65);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@ -305,7 +303,7 @@ class Countries
|
|||||||
$location = $dtze->getLocation();
|
$location = $dtze->getLocation();
|
||||||
$countryCode = strtoupper($location['country_code'] ?? '');
|
$countryCode = strtoupper($location['country_code'] ?? '');
|
||||||
$countryData = $countries[$countryCode] ?? null;
|
$countryData = $countries[$countryCode] ?? null;
|
||||||
$flag = $countryData ? $countryData['flag'] : '🌐';
|
$flag = $countryData ? $countryData['flag'] : '';
|
||||||
$countryName = $countryData ? $countryData['name'] : '';
|
$countryName = $countryData ? $countryData['name'] : '';
|
||||||
|
|
||||||
$parts = explode('/', $tz);
|
$parts = explode('/', $tz);
|
||||||
|
|||||||
152
app/Data/Languages.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ 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 App\Rules\NotDisposableEmail;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -45,6 +46,11 @@ class RegisteredUserController extends Controller
|
|||||||
|
|
||||||
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()->route('verification.notice');
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Playlist;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use App\Models\VideoSlide;
|
use App\Models\VideoSlide;
|
||||||
@ -47,6 +48,11 @@ class MediaController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Might be a playlist thumbnail
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$nas->ensureLocalAsset($local, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
if (! file_exists($local)) abort(404);
|
if (! file_exists($local)) abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +93,27 @@ class MediaController extends Controller
|
|||||||
|
|
||||||
public function avatar(string $filename, NasSyncService $nas): Response
|
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);
|
$local = storage_path('app/public/avatars/' . $filename);
|
||||||
|
|
||||||
if (! file_exists($local)) {
|
if (! file_exists($local)) {
|
||||||
@ -104,6 +131,27 @@ class MediaController extends Controller
|
|||||||
|
|
||||||
public function banner(string $filename, NasSyncService $nas): Response
|
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);
|
$local = storage_path('app/public/banners/' . $filename);
|
||||||
|
|
||||||
if (! file_exists($local)) {
|
if (! file_exists($local)) {
|
||||||
@ -119,6 +167,48 @@ class MediaController extends Controller
|
|||||||
return $this->fileResponse($local);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function fileResponse(string $path): Response
|
private function fileResponse(string $path): Response
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use App\Services\GeoIpService;
|
use App\Services\GeoIpService;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -14,7 +15,7 @@ class PlaylistController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
|
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// List user's playlists
|
// List user's playlists
|
||||||
@ -27,9 +28,8 @@ class PlaylistController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// View a single playlist
|
// View a single playlist
|
||||||
public function show(Playlist $playlist)
|
public function show(Request $request, Playlist $playlist)
|
||||||
{
|
{
|
||||||
// Check if user can view this playlist
|
|
||||||
if (! $playlist->canView(Auth::user())) {
|
if (! $playlist->canView(Auth::user())) {
|
||||||
abort(404, 'Playlist not found');
|
abort(404, 'Playlist not found');
|
||||||
}
|
}
|
||||||
@ -37,11 +37,17 @@ class PlaylistController extends Controller
|
|||||||
$playlist->loadMissing('user');
|
$playlist->loadMissing('user');
|
||||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
$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'));
|
return view('playlists.show', compact('playlist', 'videos'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// View playlist via unguessable share token (unlisted playlists)
|
// View playlist via unguessable share token (unlisted playlists)
|
||||||
public function showByToken(string $token)
|
public function showByToken(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$playlist = Playlist::where('share_token', $token)->firstOrFail();
|
$playlist = Playlist::where('share_token', $token)->firstOrFail();
|
||||||
|
|
||||||
@ -52,6 +58,10 @@ class PlaylistController extends Controller
|
|||||||
$playlist->loadMissing('user');
|
$playlist->loadMissing('user');
|
||||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||||
|
|
||||||
|
dispatch(function () use ($playlist, $request) {
|
||||||
|
$playlist->bumpViewIfNew($request);
|
||||||
|
})->afterResponse();
|
||||||
|
|
||||||
return view('playlists.show', compact('playlist', 'videos'));
|
return view('playlists.show', compact('playlist', 'videos'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,6 +129,28 @@ class PlaylistController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
$firstVideo = $playlist->videos()->orderBy('position')->first();
|
||||||
$destination = $firstVideo
|
$destination = $firstVideo
|
||||||
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
|
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
|
||||||
@ -159,9 +191,8 @@ class PlaylistController extends Controller
|
|||||||
// Handle thumbnail upload
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
$playlist->update(['thumbnail' => $nasPath]);
|
||||||
$playlist->update(['thumbnail' => $filename]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload playlist with thumbnail
|
// Reload playlist with thumbnail
|
||||||
@ -228,28 +259,19 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
// Handle thumbnail upload
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
// Delete old thumbnail if exists
|
// Delete old thumbnail from NAS if exists
|
||||||
if ($playlist->thumbnail) {
|
if ($playlist->thumbnail) {
|
||||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
if (file_exists($oldPath)) {
|
|
||||||
unlink($oldPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload new thumbnail
|
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
|
||||||
$updateData['thumbnail'] = $filename;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle thumbnail removal
|
// Handle thumbnail removal
|
||||||
if ($request->input('remove_thumbnail') == '1') {
|
if ($request->input('remove_thumbnail') == '1') {
|
||||||
if ($playlist->thumbnail) {
|
if ($playlist->thumbnail) {
|
||||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
if (file_exists($oldPath)) {
|
|
||||||
unlink($oldPath);
|
|
||||||
}
|
|
||||||
$updateData['thumbnail'] = null;
|
$updateData['thumbnail'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -328,6 +350,18 @@ class PlaylistController extends Controller
|
|||||||
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
|
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
|
// Remove video from playlist
|
||||||
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
||||||
{
|
{
|
||||||
@ -472,4 +506,142 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
|
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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,10 @@ use App\Models\Post;
|
|||||||
use App\Models\PostImage;
|
use App\Models\PostImage;
|
||||||
use App\Models\PostVideo;
|
use App\Models\PostVideo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Notifications\NewPostNotification;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
@ -29,7 +30,6 @@ class PostController extends Controller
|
|||||||
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||||
'video_ids' => 'nullable|array|max:10',
|
'video_ids' => 'nullable|array|max:10',
|
||||||
'video_ids.*' => 'exists:videos,id',
|
'video_ids.*' => 'exists:videos,id',
|
||||||
// Legacy fields
|
|
||||||
'video_id' => 'nullable|exists:videos,id',
|
'video_id' => 'nullable|exists:videos,id',
|
||||||
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||||
]);
|
]);
|
||||||
@ -43,35 +43,69 @@ class PostController extends Controller
|
|||||||
return back()->withErrors(['body' => 'Post cannot be empty.']);
|
return back()->withErrors(['body' => 'Post cannot be empty.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
// Create post first — we need the ID as the folder name
|
||||||
|
$post = Post::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
'video_id' => $request->video_id ?? null,
|
'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);
|
||||||
|
|
||||||
// Legacy single image (backward compat)
|
|
||||||
if ($hasLegacyImg) {
|
if ($hasLegacyImg) {
|
||||||
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
|
$file = $request->file('image');
|
||||||
$request->file('image')->storeAs('public/post_images', $filename);
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
$data['image'] = $filename;
|
$nasPath = "{$postDir}/0.{$ext}";
|
||||||
|
$nas->putFile($file->getRealPath(), $nasPath);
|
||||||
|
$post->update(['image' => $nasPath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$post = Post::create($data);
|
|
||||||
|
|
||||||
// New multi-image
|
|
||||||
if ($hasImages) {
|
if ($hasImages) {
|
||||||
foreach ($request->file('images') as $idx => $file) {
|
foreach ($request->file('images') as $idx => $file) {
|
||||||
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
$file->storeAs('public/post_images', $filename);
|
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
|
||||||
|
$nas->putFile($file->getRealPath(), $nasPath);
|
||||||
PostImage::create([
|
PostImage::create([
|
||||||
'post_id' => $post->id,
|
'post_id' => $post->id,
|
||||||
'filename' => $filename,
|
'filename' => $nasPath,
|
||||||
'sort_order' => $idx,
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// New multi-video
|
|
||||||
if ($hasVideoIds) {
|
if ($hasVideoIds) {
|
||||||
foreach ($request->input('video_ids') as $idx => $videoId) {
|
foreach ($request->input('video_ids') as $idx => $videoId) {
|
||||||
PostVideo::create([
|
PostVideo::create([
|
||||||
@ -82,6 +116,12 @@ class PostController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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!');
|
return back()->with('toast_success', 'Post shared!');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,14 +131,17 @@ class PostController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($post->image) {
|
$post->loadMissing('postImages');
|
||||||
Storage::delete('public/post_images/' . $post->image);
|
$nas = app(NasSyncService::class);
|
||||||
|
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
try {
|
||||||
|
$nas->deleteNasPost($post);
|
||||||
|
} catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete multi-image files
|
// Always clean up local copies (handles both legacy flat and new structured format)
|
||||||
foreach ($post->postImages as $postImage) {
|
$nas->deleteLocalPostImages($post);
|
||||||
Storage::delete('public/post_images/' . $postImage->filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
$post->delete();
|
$post->delete();
|
||||||
|
|
||||||
|
|||||||
307
app/Http/Controllers/SportsMatchController.php
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -313,12 +313,46 @@ class SuperAdminController extends Controller
|
|||||||
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if admin is already within the 30-min verified window
|
||||||
|
private function adminIsVerified(): bool
|
||||||
|
{
|
||||||
|
$admin = auth()->user();
|
||||||
|
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$verifiedAt = session('admin_2fa_verified_at');
|
||||||
|
return $verifiedAt && now()->timestamp - $verifiedAt < 1800;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates OTP and stamps the session on success
|
||||||
|
private function verify2fa(Request $request): bool
|
||||||
|
{
|
||||||
|
$admin = auth()->user();
|
||||||
|
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($this->adminIsVerified()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$code = $request->input('otp_code', '');
|
||||||
|
$google2fa = app('pragmarx.google2fa');
|
||||||
|
if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) {
|
||||||
|
session(['admin_2fa_verified_at' => now()->timestamp]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
public function deleteUser(User $user)
|
public function deleteUser(Request $request, User $user)
|
||||||
{
|
{
|
||||||
// Prevent deleting yourself
|
// Prevent deleting yourself
|
||||||
if (auth()->id() === $user->id) {
|
if (auth()->id() === $user->id) {
|
||||||
return back()->with('error', 'You cannot delete your own account!');
|
return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verify2fa($request)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
AuditLog::record('admin.user.deleted', [
|
AuditLog::record('admin.user.deleted', [
|
||||||
@ -343,7 +377,7 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
|
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all videos
|
// List all videos
|
||||||
@ -530,16 +564,33 @@ class SuperAdminController extends Controller
|
|||||||
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
|
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$oldTitle = $video->title;
|
||||||
|
|
||||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
||||||
|
|
||||||
$video->update($data);
|
$video->update($data);
|
||||||
|
|
||||||
|
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
|
||||||
|
try {
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
$nas->renameVideoDir($video->fresh());
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete video
|
// Delete video
|
||||||
public function deleteVideo(Video $video)
|
public function deleteVideo(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
|
if (! $this->verify2fa($request)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$videoTitle = $video->title;
|
$videoTitle = $video->title;
|
||||||
|
|
||||||
AuditLog::record('admin.video.deleted', [
|
AuditLog::record('admin.video.deleted', [
|
||||||
@ -561,7 +612,7 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
$video->delete();
|
$video->delete();
|
||||||
|
|
||||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -795,23 +846,25 @@ class SuperAdminController extends Controller
|
|||||||
public function settings()
|
public function settings()
|
||||||
{
|
{
|
||||||
$settings = [
|
$settings = [
|
||||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
'llm_enabled' => Setting::get('llm_enabled', 'false'),
|
||||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
|
||||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
|
||||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
|
||||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
'llm_active_id' => (string) Setting::get('llm_active_id', ''),
|
||||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
|
||||||
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$gpus = $this->probeGpus();
|
return view('admin.settings', compact('settings'));
|
||||||
$nvencWorks = $this->probeNvenc();
|
|
||||||
|
|
||||||
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings save handler — accepts partial submissions from any of the
|
||||||
|
* separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
|
||||||
|
* that appear in the request.
|
||||||
|
*/
|
||||||
public function updateSettings(Request $request)
|
public function updateSettings(Request $request)
|
||||||
{
|
{
|
||||||
|
// ── GPU section ──────────────────────────────────────────────────────
|
||||||
|
if ($request->has('gpu_enabled')) {
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'gpu_enabled' => 'required|in:true,false',
|
'gpu_enabled' => 'required|in:true,false',
|
||||||
'gpu_device' => 'required|integer|min:0|max:15',
|
'gpu_device' => 'required|integer|min:0|max:15',
|
||||||
@ -819,7 +872,6 @@ class SuperAdminController extends Controller
|
|||||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||||
'ffmpeg_binary' => 'required|string|max:255',
|
'ffmpeg_binary' => 'required|string|max:255',
|
||||||
'nas_sync_enabled' => 'required|in:true,false',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||||
@ -833,16 +885,233 @@ class SuperAdminController extends Controller
|
|||||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||||
Setting::set('gpu_preset', $request->gpu_preset);
|
Setting::set('gpu_preset', $request->gpu_preset);
|
||||||
Setting::set('ffmpeg_binary', $binary);
|
Setting::set('ffmpeg_binary', $binary);
|
||||||
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
Setting::flushGpuProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lyrics pipeline section ──────────────────────────────────────────
|
||||||
|
if ($request->has('lyrics_section')) {
|
||||||
|
foreach ([
|
||||||
|
'lyrics_enabled', // master switch
|
||||||
|
'lyrics_use_description', // align to description text
|
||||||
|
'lyrics_vad_enabled', // Silero VAD filter
|
||||||
|
'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
|
||||||
|
'lyrics_demucs_enabled', // vocal isolation (Demucs)
|
||||||
|
'lyrics_llm_decorate', // post-bake emojis via LLM
|
||||||
|
] as $k) {
|
||||||
|
Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI / LLM section ─────────────────────────────────────────────────
|
||||||
|
if ($request->has('llm_section')) {
|
||||||
|
Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
|
||||||
|
Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
|
||||||
|
Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
|
||||||
|
$this->saveLlmProviders($request);
|
||||||
|
}
|
||||||
|
|
||||||
return back()->with('success', 'Settings saved.');
|
return back()->with('success', 'Settings saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe an LLM provider endpoint: verify the connection and list
|
||||||
|
* available models. Used by the AI / LLM settings page.
|
||||||
|
*
|
||||||
|
* Accepts kind / endpoint / api_key from the form, plus an optional
|
||||||
|
* provider id so we can fall back to the saved key when the admin
|
||||||
|
* left the password field blank (placeholder ••••••••).
|
||||||
|
*/
|
||||||
|
public function llmProviderTest(Request $request)
|
||||||
|
{
|
||||||
|
$kind = (string) $request->input('kind', 'ollama');
|
||||||
|
$endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
|
||||||
|
$endpoint = rtrim($endpoint, '/');
|
||||||
|
$apiKey = (string) $request->input('api_key', '');
|
||||||
|
$id = (string) $request->input('id', '');
|
||||||
|
|
||||||
|
if ($apiKey === '' && $id !== '') {
|
||||||
|
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
|
||||||
|
foreach ($providers as $p) {
|
||||||
|
if (($p['id'] ?? '') === $id) {
|
||||||
|
$apiKey = (string) ($p['api_key'] ?? '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($kind !== 'ollama' && $apiKey === '') {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$models = match ($kind) {
|
||||||
|
'ollama' => $this->fetchOllamaModels($endpoint),
|
||||||
|
'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
|
||||||
|
'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
|
||||||
|
};
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'count' => count($models),
|
||||||
|
'models' => $models,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchOllamaModels(string $endpoint): array
|
||||||
|
{
|
||||||
|
$resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
|
||||||
|
if (! $resp->successful()) {
|
||||||
|
throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
|
||||||
|
}
|
||||||
|
$j = $resp->json();
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn ($m) => (string) ($m['name'] ?? ''),
|
||||||
|
$j['models'] ?? []
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchAnthropicModels(string $endpoint, string $apiKey): array
|
||||||
|
{
|
||||||
|
$resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
|
||||||
|
'x-api-key' => $apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
])->get($endpoint . '/v1/models');
|
||||||
|
if (! $resp->successful()) {
|
||||||
|
$body = $resp->json();
|
||||||
|
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
|
||||||
|
}
|
||||||
|
$j = $resp->json();
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn ($m) => (string) ($m['id'] ?? ''),
|
||||||
|
$j['data'] ?? []
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchOpenAIModels(string $endpoint, string $apiKey): array
|
||||||
|
{
|
||||||
|
$resp = \Illuminate\Support\Facades\Http::timeout(15)
|
||||||
|
->withToken($apiKey)->acceptJson()
|
||||||
|
->get($endpoint . '/v1/models');
|
||||||
|
if (! $resp->successful()) {
|
||||||
|
$body = $resp->json();
|
||||||
|
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
|
||||||
|
}
|
||||||
|
$j = $resp->json();
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn ($m) => (string) ($m['id'] ?? ''),
|
||||||
|
$j['data'] ?? []
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lyrics()
|
||||||
|
{
|
||||||
|
$settings = [
|
||||||
|
'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
|
||||||
|
'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
|
||||||
|
'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
|
||||||
|
'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
|
||||||
|
'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
|
||||||
|
'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
|
||||||
|
];
|
||||||
|
return view('admin.lyrics', compact('settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function gpu()
|
||||||
|
{
|
||||||
|
$settings = [
|
||||||
|
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||||
|
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||||
|
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||||
|
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||||
|
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||||
|
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$gpus = $this->probeGpus();
|
||||||
|
$nvencWorks = $this->probeNvenc();
|
||||||
|
|
||||||
|
return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backup()
|
||||||
|
{
|
||||||
|
return view('admin.backup');
|
||||||
|
}
|
||||||
|
|
||||||
public function detectGpu()
|
public function detectGpu()
|
||||||
{
|
{
|
||||||
return response()->json(['gpus' => $this->probeGpus()]);
|
return response()->json(['gpus' => $this->probeGpus()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the LLM provider list from the multi-provider form. Each row
|
||||||
|
* carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
|
||||||
|
* api_key. An empty api_key means "keep the previously stored value" so the
|
||||||
|
* admin doesn't have to retype it on every save.
|
||||||
|
*/
|
||||||
|
private function saveLlmProviders(Request $request): void
|
||||||
|
{
|
||||||
|
$existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
|
||||||
|
->keyBy(fn ($p) => $p['id'] ?? '');
|
||||||
|
|
||||||
|
$kinds = ['ollama', 'anthropic', 'openai'];
|
||||||
|
$rows = (array) $request->input('providers', []);
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
$kind = (string) ($row['kind'] ?? 'ollama');
|
||||||
|
if (! in_array($kind, $kinds, true)) $kind = 'ollama';
|
||||||
|
if ($name === '') continue;
|
||||||
|
|
||||||
|
$id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
|
||||||
|
$endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
|
||||||
|
$model = trim((string) ($row['model'] ?? ''));
|
||||||
|
$apiKeyIn = (string) ($row['api_key'] ?? '');
|
||||||
|
// Blank input → keep the previously-stored key for this id (admin
|
||||||
|
// didn't retype it). Non-blank → use the new value verbatim.
|
||||||
|
$apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'kind' => $kind,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'model' => $model,
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$activeId = trim((string) $request->input('llm_active_id', ''));
|
||||||
|
$validIds = array_column($out, 'id');
|
||||||
|
if ($activeId !== '' && in_array($activeId, $validIds, true)) {
|
||||||
|
Setting::set('llm_active_id', $activeId);
|
||||||
|
} elseif (count($validIds) === 1) {
|
||||||
|
Setting::set('llm_active_id', $validIds[0]);
|
||||||
|
} elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
|
||||||
|
Setting::set('llm_active_id', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultEndpoint(string $kind): string
|
||||||
|
{
|
||||||
|
return match ($kind) {
|
||||||
|
'anthropic' => 'https://api.anthropic.com',
|
||||||
|
'openai' => 'https://api.openai.com',
|
||||||
|
default => 'http://localhost:11434',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function probeGpus(): array
|
private function probeGpus(): array
|
||||||
{
|
{
|
||||||
$gpus = [];
|
$gpus = [];
|
||||||
@ -875,26 +1144,447 @@ class SuperAdminController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function probeNvenc(): bool
|
private function probeNvenc(): bool
|
||||||
{
|
{
|
||||||
$ffmpeg = Setting::ffmpegBinary();
|
// Single source of truth lives on the Setting model; force the NVENC encoder so the
|
||||||
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
|
// admin indicator always reflects GPU capability regardless of the configured encoder.
|
||||||
$device = Setting::gpuDevice();
|
return Setting::probeGpu('h264_nvenc');
|
||||||
|
|
||||||
exec(
|
|
||||||
escapeshellcmd($ffmpeg)
|
|
||||||
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
|
|
||||||
. " -c:v h264_nvenc -gpu {$device}"
|
|
||||||
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
|
|
||||||
$out, $exit
|
|
||||||
);
|
|
||||||
|
|
||||||
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
|
|
||||||
@unlink($tmp);
|
|
||||||
return $ok;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function nasStorage()
|
public function nasStorage()
|
||||||
{
|
{
|
||||||
$nodes = config('nas-file-manager.schema', []);
|
$nodes = config('nas-file-manager.schema', []);
|
||||||
return view('admin.nas-storage', compact('nodes'));
|
$settings = [
|
||||||
|
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
||||||
|
];
|
||||||
|
return view('admin.nas-storage', compact('nodes', 'settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nasDelete(Request $request)
|
||||||
|
{
|
||||||
|
$path = trim($request->input('path', ''));
|
||||||
|
$type = $request->input('type', 'dir');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Path is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
|
if (! $nas->isEnabled()) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($type === 'dir') {
|
||||||
|
$nas->deleteNasTree($path);
|
||||||
|
} else {
|
||||||
|
$nas->deleteFile($path);
|
||||||
|
}
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nasRepair(Request $request)
|
||||||
|
{
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
|
if (! $nas->isEnabled()) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect stuck items ───────────────────────────────────────────────
|
||||||
|
$stuckVideos = $this->collectStuckVideos();
|
||||||
|
$stuckAvatars = $this->collectStuckAvatars();
|
||||||
|
$stuckBanners = $this->collectStuckBanners();
|
||||||
|
$stuckThumbs = $this->collectStuckLegacyThumbnails();
|
||||||
|
$nasOrphans = $nas->scanNasOrphans();
|
||||||
|
|
||||||
|
$totalStuck = $stuckVideos->count() + $stuckAvatars->count()
|
||||||
|
+ $stuckBanners->count() + $stuckThumbs->count()
|
||||||
|
+ count($nasOrphans);
|
||||||
|
|
||||||
|
// Scan-only mode ───────────────────────────────────────────────────────
|
||||||
|
if ($request->boolean('scan_only')) {
|
||||||
|
$details = [];
|
||||||
|
foreach ($stuckVideos as $item) {
|
||||||
|
$details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']);
|
||||||
|
}
|
||||||
|
foreach ($stuckAvatars as $item) {
|
||||||
|
$details[] = "[avatar] {$item['user']->username}: {$item['file']}";
|
||||||
|
}
|
||||||
|
foreach ($stuckBanners as $item) {
|
||||||
|
$details[] = "[banner] {$item['user']->username}: {$item['file']}";
|
||||||
|
}
|
||||||
|
foreach ($stuckThumbs as $item) {
|
||||||
|
$details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})";
|
||||||
|
}
|
||||||
|
foreach ($nasOrphans as $orphan) {
|
||||||
|
$label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json';
|
||||||
|
$details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheBytes = $nas->nasCacheSize();
|
||||||
|
if ($cacheBytes > 0) {
|
||||||
|
$cacheMb = round($cacheBytes / 1048576, 1);
|
||||||
|
$details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)";
|
||||||
|
$totalStuck++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['stuck' => $totalStuck, 'details' => $details]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair mode ─────────────────────────────────────────────────────────
|
||||||
|
$repaired = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$details = [];
|
||||||
|
|
||||||
|
foreach ($stuckVideos as $item) {
|
||||||
|
$video = $item['video'];
|
||||||
|
try {
|
||||||
|
$nas->syncVideo($video);
|
||||||
|
$nas->deleteLocalAssets($video);
|
||||||
|
if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video);
|
||||||
|
$nas->pruneLocalVideoDir($video);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [video] #{$video->id}: {$video->title}";
|
||||||
|
\Log::info("nas:repair: fixed video #{$video->id}");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [video] #{$video->id}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckAvatars as $item) {
|
||||||
|
try {
|
||||||
|
$nas->syncAvatar($item['user'], $item['path']);
|
||||||
|
$nas->deleteLocalAvatar($item['user']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [avatar] {$item['user']->username}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckBanners as $item) {
|
||||||
|
try {
|
||||||
|
$nas->syncCover($item['user'], $item['path']);
|
||||||
|
$nas->deleteLocalBanner($item['user']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [banner] {$item['user']->username}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckThumbs as $item) {
|
||||||
|
try {
|
||||||
|
if ($item['type'] === 'thumbnail' && $item['video']) {
|
||||||
|
$nas->syncVideo($item['video']);
|
||||||
|
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
||||||
|
$dir = $nas->resolveVideoDir($item['video']);
|
||||||
|
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$nas->mkdirp("{$dir}/slides");
|
||||||
|
$nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}");
|
||||||
|
}
|
||||||
|
@unlink($item['path']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [{$item['type']}] {$item['file']}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete NAS orphan folders
|
||||||
|
foreach ($nasOrphans as $orphan) {
|
||||||
|
try {
|
||||||
|
$nas->deleteNasTree($orphan['dir']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [nas-orphan] deleted {$orphan['dir']}";
|
||||||
|
\Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}";
|
||||||
|
\Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict NAS stream cache (24h TTL by default)
|
||||||
|
$evicted = $nas->clearNasCache(24);
|
||||||
|
if ($evicted > 0) {
|
||||||
|
$details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)";
|
||||||
|
$repaired += $evicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pruneLocalStorageDirs();
|
||||||
|
|
||||||
|
if ($totalStuck === 0) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.',
|
||||||
|
'repaired' => 0, 'failed' => 0, 'details' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => $failed === 0,
|
||||||
|
'message' => $failed === 0
|
||||||
|
? "Repaired {$repaired} item(s) successfully."
|
||||||
|
: "Repaired {$repaired}, failed {$failed} — check logs.",
|
||||||
|
'repaired' => $repaired,
|
||||||
|
'failed' => $failed,
|
||||||
|
'details' => $details,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckVideos(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return \App\Models\Video::with(['user', 'slides'])->get()
|
||||||
|
->filter(fn ($v) => str_starts_with($v->path, 'users/'))
|
||||||
|
->map(function ($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 collectStuckAvatars(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$results = collect();
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
|
$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 = \App\Models\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)), '/');
|
||||||
|
$user = \App\Models\User::where('avatar', $relPath)->first();
|
||||||
|
if ($user) {
|
||||||
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckBanners(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$results = collect();
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
|
$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 = \App\Models\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 = \App\Models\User::where('banner', $relPath)->first();
|
||||||
|
if ($user) {
|
||||||
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckLegacyThumbnails(): \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;
|
||||||
|
|
||||||
|
$video = \App\Models\Video::where('thumbnail', $filename)->first();
|
||||||
|
if ($video) {
|
||||||
|
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slide = \App\Models\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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pruneLocalStorageDirs(): void
|
||||||
|
{
|
||||||
|
// NAS-mirrored tree
|
||||||
|
$nasRoot = storage_path('app/users');
|
||||||
|
if (is_dir($nasRoot)) {
|
||||||
|
$iter = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($nasRoot, \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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat asset dirs — remove if empty
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NAS Disable Flow ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function nasDisable(Request $request)
|
||||||
|
{
|
||||||
|
$mode = $request->input('mode'); // 'migrate' or 'fresh'
|
||||||
|
|
||||||
|
if ($mode === 'migrate') {
|
||||||
|
// Reset progress cache, dispatch job
|
||||||
|
\Cache::put('nas_disable_progress', json_encode([
|
||||||
|
'current' => 0, 'total' => 0,
|
||||||
|
'phase' => 'Starting...', 'done' => false, 'error' => null,
|
||||||
|
]), 3600);
|
||||||
|
\App\Jobs\NasToLocalMigrationJob::dispatch()
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === 'fresh') {
|
||||||
|
// Truncate all media tables, reset user avatars/banners, disable NAS
|
||||||
|
$tables = [
|
||||||
|
'videos','video_slides','video_likes','video_views','video_shares',
|
||||||
|
'video_downloads','playlist_videos','playlists','comments','comment_likes',
|
||||||
|
'posts','post_images','post_reactions','post_videos',
|
||||||
|
'coach_reviews','match_rounds','match_points',
|
||||||
|
'share_accesses','playlist_share_accesses','notifications',
|
||||||
|
];
|
||||||
|
foreach ($tables as $t) {
|
||||||
|
\DB::table($t)->delete();
|
||||||
|
}
|
||||||
|
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
|
||||||
|
Setting::set('nas_sync_enabled', 'false');
|
||||||
|
app(\App\Services\NasSyncService::class)->flushReachabilityCache();
|
||||||
|
AuditLog::record('admin.nas_disabled_fresh');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nasMigrateProgress()
|
||||||
|
{
|
||||||
|
$raw = \Cache::get('nas_disable_progress');
|
||||||
|
if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']);
|
||||||
|
return response()->json(json_decode($raw, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backupUsersSettings()
|
||||||
|
{
|
||||||
|
$users = \DB::table('users')->get()->map(function ($u) {
|
||||||
|
return (array) $u;
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$settings = \DB::table('settings')->get()->map(function ($s) {
|
||||||
|
return (array) $s;
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'version' => '1.0',
|
||||||
|
'exported_at' => now()->toIso8601String(),
|
||||||
|
'users' => $users,
|
||||||
|
'settings' => $settings,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return response($payload, 200, [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreUsersSettings(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate(['backup' => 'required|file|mimes:json|max:10240']);
|
||||||
|
|
||||||
|
$content = file_get_contents($request->file('backup')->getRealPath());
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
|
||||||
|
if (! isset($data['users']) || ! isset($data['settings'])) {
|
||||||
|
return back()->with('toast_error', 'Invalid backup file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore settings
|
||||||
|
foreach ($data['settings'] as $row) {
|
||||||
|
\DB::table('settings')->updateOrInsert(
|
||||||
|
['key' => $row['key']],
|
||||||
|
['key' => $row['key'], 'value' => $row['value']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore users (upsert by email)
|
||||||
|
$restored = 0;
|
||||||
|
foreach ($data['users'] as $row) {
|
||||||
|
unset($row['id']); // let DB assign new IDs to avoid PK conflicts
|
||||||
|
\DB::table('users')->updateOrInsert(
|
||||||
|
['email' => $row['email']],
|
||||||
|
$row
|
||||||
|
);
|
||||||
|
$restored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('admin.backup_restored', ['users' => $restored]);
|
||||||
|
return back()->with('toast_success', "Backup restored: {$restored} users + settings.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ use App\Models\AuditLog;
|
|||||||
use App\Models\Post;
|
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;
|
||||||
@ -19,6 +21,33 @@ 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)
|
||||||
|
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
|
// Profile page - personal overview for the authenticated user
|
||||||
public function profile()
|
public function profile()
|
||||||
{
|
{
|
||||||
@ -68,17 +97,36 @@ class UserController extends Controller
|
|||||||
'timezone' => $request->timezone ?: null,
|
'timezone' => $request->timezone ?: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
if ($request->hasFile('avatar')) {
|
if ($request->hasFile('avatar')) {
|
||||||
if ($user->avatar) {
|
// Delete old avatar (handles both flat and new relative-path formats)
|
||||||
Storage::delete('public/avatars/'.$user->avatar);
|
$nas->deleteLocalAvatar($user);
|
||||||
}
|
|
||||||
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
|
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
|
||||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
$profileDir = $nas->localProfileDir($user);
|
||||||
$data['avatar'] = $filename;
|
$destFilename = "avatar.{$ext}";
|
||||||
|
$destPath = "{$profileDir}/{$destFilename}";
|
||||||
|
$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
|
||||||
|
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
|
// Sync social links
|
||||||
$user->socialLinks()->delete();
|
$user->socialLinks()->delete();
|
||||||
$order = 0;
|
$order = 0;
|
||||||
@ -132,6 +180,22 @@ class UserController extends Controller
|
|||||||
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
|
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
|
// Logout all other devices
|
||||||
public function logoutAllDevices(Request $request)
|
public function logoutAllDevices(Request $request)
|
||||||
{
|
{
|
||||||
@ -193,9 +257,10 @@ class UserController extends Controller
|
|||||||
|
|
||||||
$videos = $baseQuery->where('is_shorts', false)->get();
|
$videos = $baseQuery->where('is_shorts', false)->get();
|
||||||
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
|
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
|
||||||
|
$withFirstVideo = fn($q) => $q->orderBy('playlist_videos.position')->limit(1);
|
||||||
$playlists = $isOwner
|
$playlists = $isOwner
|
||||||
? $user->playlists()->orderBy('created_at', 'desc')->get()
|
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
||||||
: $user->playlists()->public()->where('is_default', false)->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')
|
$totalViews = \DB::table('video_views')
|
||||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||||
@ -329,6 +394,9 @@ 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([
|
||||||
@ -337,7 +405,49 @@ class UserController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleSubscribe(User $user)
|
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();
|
$me = Auth::user();
|
||||||
|
|
||||||
@ -349,8 +459,13 @@ class UserController extends Controller
|
|||||||
$me->subscriptions()->detach($user->id);
|
$me->subscriptions()->detach($user->id);
|
||||||
$subscribed = false;
|
$subscribed = false;
|
||||||
} else {
|
} else {
|
||||||
$me->subscriptions()->attach($user->id);
|
$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;
|
$subscribed = true;
|
||||||
|
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -359,34 +474,45 @@ class UserController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notificationCount()
|
||||||
|
{
|
||||||
|
return response()->json(['unread_count' => Auth::user()->unreadNotifications()->count()]);
|
||||||
|
}
|
||||||
|
|
||||||
public function fetchNotifications()
|
public function fetchNotifications()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
$rawNotifications = $user->notifications()->latest()->take(50)->get();
|
$rawNotifications = $user->notifications()->latest()->take(50)->get();
|
||||||
|
|
||||||
// Bulk-fetch current video state for all notification types
|
// Bulk-fetch video state only for video-linked notifications
|
||||||
$videoIds = $rawNotifications
|
$videoIds = $rawNotifications
|
||||||
->pluck('data.video_id')
|
->pluck('data.video_id')
|
||||||
->filter()
|
->filter()
|
||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$videos = \App\Models\Video::whereIn('id', $videoIds)
|
$videos = $videoIds->isNotEmpty()
|
||||||
|
? \App\Models\Video::whereIn('id', $videoIds)
|
||||||
->whereIn('visibility', ['public', 'unlisted'])
|
->whereIn('visibility', ['public', 'unlisted'])
|
||||||
->get(['id', 'thumbnail', 'visibility'])
|
->get(['id', 'thumbnail', 'visibility'])
|
||||||
->keyBy('id');
|
->keyBy('id')
|
||||||
|
: collect();
|
||||||
|
|
||||||
$notifications = $rawNotifications
|
$notifications = $rawNotifications
|
||||||
->filter(function ($n) use ($videos) {
|
->filter(function ($n) use ($videos) {
|
||||||
$videoId = $n->data['video_id'] ?? null;
|
$videoId = $n->data['video_id'] ?? null;
|
||||||
return $videoId && $videos->has($videoId);
|
// 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)
|
->take(30)
|
||||||
->map(function ($n) use ($videos) {
|
->map(function ($n) use ($videos) {
|
||||||
$data = $n->data;
|
$data = $n->data;
|
||||||
$video = $videos->get($data['video_id']);
|
if (!empty($data['video_id'])) {
|
||||||
$data['video_thumbnail'] = $video?->thumbnail ?? null;
|
$data['video_thumbnail'] = $videos->get($data['video_id'])?->thumbnail ?? null;
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'id' => $n->id,
|
'id' => $n->id,
|
||||||
'read' => ! is_null($n->read_at),
|
'read' => ! is_null($n->read_at),
|
||||||
@ -418,14 +544,68 @@ class UserController extends Controller
|
|||||||
public function updateAvatar(Request $request)
|
public function updateAvatar(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate(['path' => 'required|string|max:300']);
|
$request->validate(['path' => 'required|string|max:300']);
|
||||||
Auth::user()->update(['avatar' => basename($request->path)]);
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateBanner(Request $request)
|
public function updateBanner(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate(['path' => 'required|string|max:300']);
|
$request->validate(['path' => 'required|string|max:300']);
|
||||||
Auth::user()->update(['banner' => basename($request->path)]);
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,9 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
]);
|
]);
|
||||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||||
|
|
||||||
$gpuEnabled = Setting::gpuEnabled();
|
// 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();
|
$encoder = Setting::gpuEncoder();
|
||||||
$preset = Setting::gpuPreset();
|
$preset = Setting::gpuPreset();
|
||||||
$device = Setting::gpuDevice();
|
$device = Setting::gpuDevice();
|
||||||
|
|||||||
139
app/Jobs/DecorateLyricsJob.php
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Models\VideoAudioTrack;
|
||||||
|
use App\Services\LlmLyricsService;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
|
||||||
|
* Original words are preserved verbatim; emojis are layered on top (in-line +
|
||||||
|
* trailing, multiple per line) per the admin's decoration prompt.
|
||||||
|
*
|
||||||
|
* Runs as its own job so a flaky LLM call can never delay or fail a successful
|
||||||
|
* transcription. Safe to re-run — already-decorated lines are skipped, so a
|
||||||
|
* second pass only fills in gaps.
|
||||||
|
*/
|
||||||
|
class DecorateLyricsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 600;
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
/** Languages written without spaces between words (mirrors transcribe.py). */
|
||||||
|
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
||||||
|
|
||||||
|
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||||
|
{
|
||||||
|
$this->onQueue('video-processing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(LlmLyricsService $llm, NasSyncService $nas): void
|
||||||
|
{
|
||||||
|
// Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
|
||||||
|
// page) gates this job, and the LLM-service-level switch (AI/LLM page)
|
||||||
|
// gates the LLM call inside it. Either being OFF skips decoration.
|
||||||
|
if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
|
||||||
|
if (! $llm->decorateEnabled()) return;
|
||||||
|
|
||||||
|
$video = Video::find($this->videoId);
|
||||||
|
if (! $video) return;
|
||||||
|
|
||||||
|
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
|
||||||
|
if ($this->trackId && ! $track) return;
|
||||||
|
|
||||||
|
$data = $nas->getLyrics($video, $track);
|
||||||
|
if (! is_array($data) || empty($data['lines'])) return;
|
||||||
|
if (($data['status'] ?? null) !== 'ready') return;
|
||||||
|
|
||||||
|
// Decorate only lines that haven't been decorated yet — a re-run fills
|
||||||
|
// gaps cheaply instead of re-stamping the whole song.
|
||||||
|
$texts = [];
|
||||||
|
$indices = [];
|
||||||
|
foreach ($data['lines'] as $i => $ln) {
|
||||||
|
if (! empty($ln['decorated'])) continue;
|
||||||
|
$t = (string) ($ln['text'] ?? '');
|
||||||
|
if (trim($t) === '') continue;
|
||||||
|
$texts[] = $t;
|
||||||
|
$indices[] = $i;
|
||||||
|
}
|
||||||
|
if (! $texts) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decorated = $llm->decorateLines($texts);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $decorated) return;
|
||||||
|
|
||||||
|
$applied = 0;
|
||||||
|
foreach ($decorated as $localIdx => $newText) {
|
||||||
|
if (! isset($indices[$localIdx])) continue;
|
||||||
|
$globalIdx = $indices[$localIdx];
|
||||||
|
$line = &$data['lines'][$globalIdx];
|
||||||
|
|
||||||
|
$line['text'] = $newText;
|
||||||
|
$line['decorated'] = true;
|
||||||
|
// The words array no longer matches the new (emoji-laced) text. We
|
||||||
|
// redistribute the existing [start,end] window evenly across the
|
||||||
|
// new tokens so the karaoke word-highlight still tracks the audio.
|
||||||
|
// Tokens that are pure emoji get the same per-slot timing as words.
|
||||||
|
$lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
|
||||||
|
$line['words'] = $this->redistributeWords(
|
||||||
|
(float) ($line['start'] ?? 0),
|
||||||
|
(float) ($line['end'] ?? 0),
|
||||||
|
$newText, $lang
|
||||||
|
);
|
||||||
|
unset($line);
|
||||||
|
$applied++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $applied) return;
|
||||||
|
|
||||||
|
$data['decorated_at'] = now()->toIso8601String();
|
||||||
|
$nas->putLyrics($video, $track, $data);
|
||||||
|
|
||||||
|
Log::info('DecorateLyricsJob: done', [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
'decorated' => $applied,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evenly distribute [start,end] across the line's tokens. Words for spaced
|
||||||
|
* languages, characters for spaceless scripts (Thai/CJK/…). Used after
|
||||||
|
* decoration so the karaoke word-highlight still tracks the audio.
|
||||||
|
*/
|
||||||
|
private function redistributeWords(float $start, float $end, string $text, string $lang): array
|
||||||
|
{
|
||||||
|
if ($text === '' || $end <= $start) return [];
|
||||||
|
$spaceless = in_array($lang, self::SPACELESS_LANGS, true);
|
||||||
|
$tokens = $spaceless
|
||||||
|
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
|
||||||
|
: preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
$n = count($tokens ?: []);
|
||||||
|
if ($n === 0) return [];
|
||||||
|
$slot = ($end - $start) / $n;
|
||||||
|
$out = [];
|
||||||
|
foreach ($tokens as $i => $t) {
|
||||||
|
$out[] = [
|
||||||
|
'start' => round($start + $i * $slot, 3),
|
||||||
|
'end' => round($start + ($i + 1) * $slot, 3),
|
||||||
|
'text' => $t,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,7 +52,12 @@ class GenerateHlsJob implements ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$hlsDir = 'public/hls/' . $video->id;
|
// 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);
|
$hlsPath = storage_path('app/' . $hlsDir);
|
||||||
|
|
||||||
if (is_dir($hlsPath)) {
|
if (is_dir($hlsPath)) {
|
||||||
@ -72,7 +77,9 @@ class GenerateHlsJob implements ShouldQueue
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
|
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
|
||||||
$gpuEnabled = Setting::gpuEnabled();
|
// 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
|
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
|
||||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||||
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
|
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
|
||||||
|
|||||||
244
app/Jobs/GenerateLyricsJob.php
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Models\VideoAudioTrack;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate word-level synced lyrics for one audio track (the video's primary
|
||||||
|
* audio when $trackId is null, otherwise a specific extra-language track).
|
||||||
|
*
|
||||||
|
* Output is a per-track lyrics JSON written through NasSyncService::putLyrics()
|
||||||
|
* — source-of-truth, synced to NAS, never under cache/. Runs the GPU pipeline
|
||||||
|
* exactly once; playback just loads the file afterwards.
|
||||||
|
*/
|
||||||
|
class GenerateLyricsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 3600;
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||||
|
{
|
||||||
|
$this->onQueue('video-processing');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared progress-file path (written by the pipeline, read by the status endpoint). */
|
||||||
|
public static function progressPath(int $videoId, ?int $trackId): string
|
||||||
|
{
|
||||||
|
return storage_path('app/tmp/lyrics_prog_' . $videoId . '_' . ($trackId ?? 'primary') . '.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the GPU with the most free memory, or null if it can't be queried. */
|
||||||
|
private function freestGpu(): ?int
|
||||||
|
{
|
||||||
|
$out = []; $code = 1;
|
||||||
|
@exec('nvidia-smi --query-gpu=index,memory.free --format=csv,noheader,nounits 2>/dev/null', $out, $code);
|
||||||
|
if ($code !== 0 || empty($out)) return null;
|
||||||
|
$best = null; $bestFree = -1;
|
||||||
|
foreach ($out as $line) {
|
||||||
|
$parts = array_map('trim', explode(',', $line));
|
||||||
|
if (count($parts) < 2) continue;
|
||||||
|
$idx = (int) $parts[0]; $free = (int) $parts[1];
|
||||||
|
if ($free > $bestFree) { $bestFree = $free; $best = $idx; }
|
||||||
|
}
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(NasSyncService $nas): void
|
||||||
|
{
|
||||||
|
$video = Video::find($this->videoId);
|
||||||
|
if (! $video) return;
|
||||||
|
|
||||||
|
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
|
||||||
|
if ($this->trackId && ! $track) return;
|
||||||
|
|
||||||
|
$language = $track ? $track->language : $video->language;
|
||||||
|
|
||||||
|
// Mark as processing so the UI can show a generating state before the file lands.
|
||||||
|
$nas->putLyrics($video, $track, [
|
||||||
|
'version' => 1,
|
||||||
|
'status' => 'processing',
|
||||||
|
'language' => $language,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve a readable local copy of the audio (downloads from NAS if needed).
|
||||||
|
$audioPath = $track ? $nas->ensureLocalTrackCopy($track) : $nas->ensureLocalCopy($video);
|
||||||
|
$nasDownloaded = $audioPath && str_starts_with($audioPath, storage_path('app/nas_cache/'))
|
||||||
|
? $audioPath : null;
|
||||||
|
|
||||||
|
if (! $audioPath || ! file_exists($audioPath)) {
|
||||||
|
Log::error('GenerateLyricsJob: audio file unavailable', [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
]);
|
||||||
|
$nas->putLyrics($video, $track, [
|
||||||
|
'version' => 1, 'status' => 'failed', 'language' => $language,
|
||||||
|
'error' => 'audio file unavailable',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$python = base_path('ml/venv/bin/python');
|
||||||
|
$script = base_path('ml/transcribe.py');
|
||||||
|
$outTmp = storage_path('app/tmp/lyrics_' . $this->videoId . '_' . ($this->trackId ?? 'primary') . '.json');
|
||||||
|
$progress = self::progressPath($this->videoId, $this->trackId);
|
||||||
|
if (! is_dir(dirname($outTmp))) @mkdir(dirname($outTmp), 0775, true);
|
||||||
|
@file_put_contents($progress, json_encode(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']));
|
||||||
|
|
||||||
|
// Model/weight downloads land in a www-data-writable cache, not root's $HOME.
|
||||||
|
$cacheDir = base_path('ml/cache');
|
||||||
|
if (! is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
|
||||||
|
|
||||||
|
// NOTE: we deliberately do NOT force --language. The stored label is just
|
||||||
|
// metadata and is often wrong (e.g. a Tagalog song mislabeled "en"), which
|
||||||
|
// made WhisperX transcribe the wrong language. Auto-detecting from the
|
||||||
|
// isolated vocals is ground truth; the detected language is saved instead.
|
||||||
|
$args = [$python, $script, '--audio', $audioPath, '--out', $outTmp, '--progress', $progress];
|
||||||
|
|
||||||
|
// Pipeline feature toggles (admin → Lyrics Pipeline). Defaults preserve
|
||||||
|
// current behavior; admin can disable any sub-step that misbehaves.
|
||||||
|
$useDescription = Setting::get('lyrics_use_description', 'true') === 'true';
|
||||||
|
$vadEnabled = Setting::get('lyrics_vad_enabled', 'true') === 'true';
|
||||||
|
$vocalGapFill = Setting::get('lyrics_vocal_region_gapfill', 'true') === 'true';
|
||||||
|
$demucsEnabled = Setting::get('lyrics_demucs_enabled', 'false') === 'true';
|
||||||
|
|
||||||
|
if (! $vadEnabled) $args[] = '--no-vad';
|
||||||
|
if (! $vocalGapFill) $args[] = '--no-vocal-gapfill';
|
||||||
|
|
||||||
|
// If the song's description contains the lyrics (typed by the uploader),
|
||||||
|
// pass them to the pipeline so it ALIGNS those exact lines to the audio
|
||||||
|
// instead of generating noisier text from scratch. Only for the primary
|
||||||
|
// track — extra-language tracks have their own audio and aren't paired
|
||||||
|
// with the description text.
|
||||||
|
$userLyrFile = null;
|
||||||
|
if ($useDescription && ! $this->trackId && $video->description) {
|
||||||
|
// Prefer the deterministic regex parser. It strips emojis line-by-line
|
||||||
|
// without touching the underlying words, so it preserves every
|
||||||
|
// language a multilingual song contains (e.g. an English+Thai song
|
||||||
|
// keeps both halves). The LLM cleaner is only a backup for cases
|
||||||
|
// where the regex returns nothing — we've seen the LLM silently
|
||||||
|
// drop whole verses that happened to be wrapped in emoji decoration.
|
||||||
|
$descLines = \App\Support\LyricsDescriptionParser::extract($video->description);
|
||||||
|
$source = 'regex';
|
||||||
|
|
||||||
|
if (empty($descLines)) {
|
||||||
|
$llm = app(\App\Services\LlmLyricsService::class);
|
||||||
|
if ($llm->cleanLyricsEnabled()) {
|
||||||
|
try {
|
||||||
|
$descLines = $llm->cleanDescription($video->description);
|
||||||
|
$source = 'llm';
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('LLM clean failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($descLines) {
|
||||||
|
$userLyrFile = storage_path('app/tmp/userlyr_' . $this->videoId . '.txt');
|
||||||
|
file_put_contents($userLyrFile, implode("\n", $descLines));
|
||||||
|
$args[] = '--user-lyrics';
|
||||||
|
$args[] = $userLyrFile;
|
||||||
|
// With description lyrics, Whisper is only providing word-timing
|
||||||
|
// anchors — its actual transcription text is discarded by the
|
||||||
|
// aligner. Vocal isolation (Demucs) helps transcription QUALITY
|
||||||
|
// but is unnecessary for timing, AND the Demucs→Whisper CUDA-
|
||||||
|
// context handoff has caused intermittent 50% futex deadlocks.
|
||||||
|
// So we skip Demucs in this mode by default; the admin can
|
||||||
|
// re-enable via the Lyrics Pipeline page.
|
||||||
|
$args[] = '--no-demucs';
|
||||||
|
Log::info('GenerateLyricsJob: using description lyrics', [
|
||||||
|
'video_id' => $this->videoId, 'lines' => count($descLines),
|
||||||
|
'source' => $source, 'demucs' => false,
|
||||||
|
'vad' => $vadEnabled, 'vocal_gapfill' => $vocalGapFill,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Honor the admin Demucs toggle for tracks WITHOUT description lyrics
|
||||||
|
// (where Whisper's transcription quality actually matters).
|
||||||
|
if (! $userLyrFile && ! $demucsEnabled) {
|
||||||
|
$args[] = '--no-demucs';
|
||||||
|
}
|
||||||
|
if (Setting::gpuUsable()) {
|
||||||
|
// Run on the GPU with the most free VRAM so a busy card never forces an
|
||||||
|
// out-of-memory fall back to slow CPU. With two cards this keeps every
|
||||||
|
// generation on the GPU and fast.
|
||||||
|
$args[] = '--gpu';
|
||||||
|
$args[] = (string) ($this->freestGpu() ?? Setting::gpuDevice());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('GenerateLyricsJob: starting', [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
'language' => $language, 'gpu' => Setting::gpuUsable(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = Process::timeout($this->timeout)
|
||||||
|
->env([
|
||||||
|
'HOME' => $cacheDir,
|
||||||
|
'XDG_CACHE_HOME' => $cacheDir,
|
||||||
|
'HF_HOME' => $cacheDir . '/huggingface',
|
||||||
|
'TORCH_HOME' => $cacheDir . '/torch',
|
||||||
|
// Demucs runs as a subprocess BEFORE faster-whisper is imported.
|
||||||
|
// If OpenMP gets initialised in the parent before that fork, the
|
||||||
|
// post-fork CUDA/ctranslate2 stack can deadlock in futex_wait —
|
||||||
|
// we've seen this hang lyrics jobs at 50% indefinitely. Forcing
|
||||||
|
// single-threaded OpenMP in the parent eliminates the race
|
||||||
|
// (faster-whisper sets its own thread count internally anyway).
|
||||||
|
'OMP_NUM_THREADS' => '1',
|
||||||
|
'MKL_NUM_THREADS' => '1',
|
||||||
|
'OPENBLAS_NUM_THREADS' => '1',
|
||||||
|
])
|
||||||
|
->run($args);
|
||||||
|
|
||||||
|
if (! $result->successful() || ! file_exists($outTmp)) {
|
||||||
|
throw new \RuntimeException('transcribe.py failed: ' . substr($result->errorOutput(), -2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode((string) file_get_contents($outTmp), true);
|
||||||
|
if (! is_array($data) || empty($data['lines'])) {
|
||||||
|
throw new \RuntimeException('transcribe.py produced no lines');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['status'] = 'ready';
|
||||||
|
$data['generated_at'] = now()->toIso8601String();
|
||||||
|
$data['language'] = $data['language'] ?? $language;
|
||||||
|
|
||||||
|
$nas->putLyrics($video, $track, $data);
|
||||||
|
|
||||||
|
// Decoration is independent of the audio pipeline — kick it off as
|
||||||
|
// its own job so a flaky LLM call can't delay or fail a successful
|
||||||
|
// transcription. Skips itself silently if the decorator is off.
|
||||||
|
DecorateLyricsJob::dispatch($this->videoId, $this->trackId)
|
||||||
|
->onConnection('database');
|
||||||
|
|
||||||
|
Log::info('GenerateLyricsJob: done', [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
'lines' => count($data['lines']),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('GenerateLyricsJob failed: ' . $e->getMessage(), [
|
||||||
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||||
|
]);
|
||||||
|
$nas->putLyrics($video, $track, [
|
||||||
|
'version' => 1, 'status' => 'failed', 'language' => $language,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
@unlink($outTmp);
|
||||||
|
@unlink($progress);
|
||||||
|
if ($userLyrFile) @unlink($userLyrFile);
|
||||||
|
if ($nasDownloaded) @unlink($nasDownloaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,10 +30,15 @@ class NasSyncVideoJob implements ShouldQueue
|
|||||||
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
||||||
if ($this->video->type === 'music') {
|
if ($this->video->type === 'music') {
|
||||||
$nas->deleteLocalVideo($this->video);
|
$nas->deleteLocalVideo($this->video);
|
||||||
$nas->deleteLocalAssets($this->video);
|
|
||||||
}
|
}
|
||||||
|
$nas->deleteLocalAssets($this->video);
|
||||||
|
$nas->pruneLocalVideoDir($this->video);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
|
\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.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
app/Jobs/NasToLocalMigrationJob.php
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Mail/VideoShared.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,10 +17,12 @@ class Playlist extends Model
|
|||||||
'visibility',
|
'visibility',
|
||||||
'is_default',
|
'is_default',
|
||||||
'share_token',
|
'share_token',
|
||||||
|
'view_count',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_default' => 'boolean',
|
'is_default' => 'boolean',
|
||||||
|
'view_count' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
@ -43,13 +45,12 @@ class Playlist extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
public function getThumbnailUrlAttribute()
|
public function getThumbnailUrlAttribute(): string
|
||||||
{
|
{
|
||||||
if ($this->thumbnail) {
|
if ($this->thumbnail) {
|
||||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
return route('media.thumbnail', $this->thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a placeholder based on playlist name
|
|
||||||
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,12 +85,87 @@ class Playlist extends Model
|
|||||||
return "{$minutes}m";
|
return "{$minutes}m";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total views of all videos in playlist
|
// Total of every viewer-session aggregated across the playlist's videos.
|
||||||
|
// Kept for the analytics-style "video time watched" metric — for the
|
||||||
|
// playlist's OWN view counter (cards, share link), use $playlist->view_count
|
||||||
|
// which is incremented per-device by bumpViewIfNew().
|
||||||
public function getTotalViewsAttribute()
|
public function getTotalViewsAttribute()
|
||||||
{
|
{
|
||||||
return $this->videos()->get()->sum('view_count');
|
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
|
// Check if user owns this playlist
|
||||||
public function isOwnedBy($user)
|
public function isOwnedBy($user)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -46,6 +46,10 @@ class Post extends Model
|
|||||||
|
|
||||||
public function getImageUrlAttribute(): ?string
|
public function getImageUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
return $this->image ? asset('storage/post_images/' . $this->image) : null;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,9 @@ class PostImage extends Model
|
|||||||
|
|
||||||
public function getImageUrlAttribute(): string
|
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);
|
return asset('storage/post_images/' . $this->filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Models/ProfileVisit.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Setting extends Model
|
class Setting extends Model
|
||||||
{
|
{
|
||||||
@ -31,6 +33,7 @@ class Setting extends Model
|
|||||||
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
|
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
|
public static function gpuEnabled(): bool
|
||||||
{
|
{
|
||||||
return static::get('gpu_enabled', 'true') === 'true';
|
return static::get('gpu_enabled', 'true') === 'true';
|
||||||
@ -41,16 +44,105 @@ class Setting extends Model
|
|||||||
return (int) static::get('gpu_device', '0');
|
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
|
public static function gpuEncoder(): string
|
||||||
{
|
{
|
||||||
return static::gpuEnabled()
|
return static::gpuUsable()
|
||||||
? static::get('gpu_encoder', 'h264_nvenc')
|
? static::get('gpu_encoder', 'h264_nvenc')
|
||||||
: 'libx264';
|
: 'libx264';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function gpuPreset(): string
|
public static function gpuPreset(): string
|
||||||
{
|
{
|
||||||
return static::gpuEnabled()
|
return static::gpuUsable()
|
||||||
? static::get('gpu_preset', 'p4')
|
? static::get('gpu_preset', 'p4')
|
||||||
: 'fast';
|
: 'fast';
|
||||||
}
|
}
|
||||||
@ -63,7 +155,7 @@ class Setting extends Model
|
|||||||
/** Returns the full video codec flags for FFmpeg shell commands. */
|
/** Returns the full video codec flags for FFmpeg shell commands. */
|
||||||
public static function ffmpegVideoFlags(bool $stillImage = false): string
|
public static function ffmpegVideoFlags(bool $stillImage = false): string
|
||||||
{
|
{
|
||||||
if (static::gpuEnabled()) {
|
if (static::gpuUsable()) {
|
||||||
$enc = static::get('gpu_encoder', 'h264_nvenc');
|
$enc = static::get('gpu_encoder', 'h264_nvenc');
|
||||||
$preset = static::get('gpu_preset', 'p4');
|
$preset = static::get('gpu_preset', 'p4');
|
||||||
$device = static::gpuDevice();
|
$device = static::gpuDevice();
|
||||||
@ -84,7 +176,7 @@ class Setting extends Model
|
|||||||
/** Returns hwaccel decode flags when the input source is a video file. */
|
/** Returns hwaccel decode flags when the input source is a video file. */
|
||||||
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
||||||
{
|
{
|
||||||
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
|
if (! $inputIsVideo || ! static::gpuUsable()) return '';
|
||||||
$hwaccel = static::get('gpu_hwaccel', 'cuda');
|
$hwaccel = static::get('gpu_hwaccel', 'cuda');
|
||||||
$device = static::gpuDevice();
|
$device = static::gpuDevice();
|
||||||
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
|
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
|
||||||
|
|||||||
51
app/Models/SportsMatch.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'two_factor_secret',
|
'two_factor_secret',
|
||||||
'two_factor_enabled',
|
'two_factor_enabled',
|
||||||
'banner',
|
'banner',
|
||||||
|
'notification_preferences',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@ -54,8 +55,43 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'two_factor_enabled' => 'boolean',
|
'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'];
|
protected $appends = ['avatar_url', 'banner_url'];
|
||||||
|
|
||||||
// Auto-generate a unique slug-based username when creating a user without one
|
// Auto-generate a unique slug-based username when creating a user without one
|
||||||
@ -123,10 +159,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return $this->hasMany(\App\Models\Post::class);
|
return $this->hasMany(\App\Models\Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAvatarUrlAttribute()
|
public function getAvatarUrlAttribute(): string
|
||||||
{
|
{
|
||||||
if ($this->avatar) {
|
if ($this->avatar) {
|
||||||
return asset('storage/avatars/'.$this->avatar);
|
return route('media.avatar', $this->avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||||
@ -135,7 +171,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
public function getBannerUrlAttribute(): ?string
|
public function getBannerUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
if ($this->banner) {
|
if ($this->banner) {
|
||||||
return asset('storage/banners/'.$this->banner);
|
return route('media.banner', $this->banner);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -169,7 +205,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'user_subscriptions',
|
'user_subscriptions',
|
||||||
'channel_id',
|
'channel_id',
|
||||||
'subscriber_id'
|
'subscriber_id'
|
||||||
)->withPivot('created_at');
|
)->withPivot(['created_at', 'source_video_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels this user subscribes to
|
// Channels this user subscribes to
|
||||||
@ -180,7 +216,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'user_subscriptions',
|
'user_subscriptions',
|
||||||
'subscriber_id',
|
'subscriber_id',
|
||||||
'channel_id'
|
'channel_id'
|
||||||
)->withPivot('created_at');
|
)->withPivot(['created_at', 'source_video_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSubscribedTo(User $channel): bool
|
public function isSubscribedTo(User $channel): bool
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class Video extends Model
|
|||||||
'share_count',
|
'share_count',
|
||||||
'share_token',
|
'share_token',
|
||||||
'slideshow_video_path',
|
'slideshow_video_path',
|
||||||
|
'language',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -64,11 +65,50 @@ class Video extends Model
|
|||||||
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function audioTracks()
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function hasSlideshow(): bool
|
public function hasSlideshow(): bool
|
||||||
{
|
{
|
||||||
return $this->slides()->count() > 1;
|
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 ─────────────────────────────────────────
|
// ── Local filesystem path helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
33
app/Models/VideoAudioTrack.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
class VideoSlide extends Model
|
class VideoSlide extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['video_id', 'filename', 'position'];
|
protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
|
||||||
|
|
||||||
public function video()
|
public function video()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Video::class);
|
return $this->belongsTo(Video::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function audioTrack()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getUrlAttribute(): string
|
public function getUrlAttribute(): string
|
||||||
{
|
{
|
||||||
return route('media.thumbnail', $this->filename);
|
return route('media.thumbnail', $this->filename);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Notifications;
|
|||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@ -18,7 +19,10 @@ class NewCommentLikeNotification extends Notification
|
|||||||
|
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['database'];
|
$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
|
public function toArray(object $notifiable): array
|
||||||
@ -35,4 +39,16 @@ class NewCommentLikeNotification extends Notification
|
|||||||
'comment_preview' => Str::limit($this->comment->body, 80),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Notifications;
|
|||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@ -18,7 +19,10 @@ class NewCommentNotification extends Notification
|
|||||||
|
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['database'];
|
$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
|
public function toArray(object $notifiable): array
|
||||||
@ -35,4 +39,16 @@ class NewCommentNotification extends Notification
|
|||||||
'comment_preview' => Str::limit($this->comment->body, 80),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Notifications/NewPostNotification.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ namespace App\Notifications;
|
|||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@ -18,7 +19,10 @@ class NewReplyNotification extends Notification
|
|||||||
|
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['database'];
|
$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
|
public function toArray(object $notifiable): array
|
||||||
@ -35,4 +39,16 @@ class NewReplyNotification extends Notification
|
|||||||
'comment_preview' => Str::limit($this->reply->body, 80),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Notifications/NewSubscriberNotification.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Notifications/NewUserRegistered.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,17 +4,19 @@ namespace App\Notifications;
|
|||||||
|
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
class NewVideoUploaded extends Notification
|
class NewVideoUploaded extends Notification
|
||||||
{
|
{
|
||||||
public function __construct(public Video $video, public User $uploader)
|
public function __construct(public Video $video, public User $uploader) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['database'];
|
$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
|
public function toArray(object $notifiable): array
|
||||||
@ -30,4 +32,15 @@ class NewVideoUploaded extends Notification
|
|||||||
'uploader_avatar' => $this->uploader->avatar_url,
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Notifications/VideoLikedNotification.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Notifications/VideoSharedWithUser.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Notifications/WeeklyDigestNotification.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,5 +32,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Universal pagination view — used everywhere by default
|
// Universal pagination view — used everywhere by default
|
||||||
Paginator::defaultView('partials.pagination');
|
Paginator::defaultView('partials.pagination');
|
||||||
Paginator::defaultSimpleView('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
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
266
app/Services/LlmLyricsService.php
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional LLM helper for the lyrics pipeline.
|
||||||
|
*
|
||||||
|
* Supports multiple providers configured in Admin → Settings → AI / LLM
|
||||||
|
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
|
||||||
|
* Picks the provider flagged "Active" and dispatches the request through the
|
||||||
|
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
|
||||||
|
* the local model.
|
||||||
|
*/
|
||||||
|
class LlmLyricsService
|
||||||
|
{
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return Setting::get('llm_enabled', 'false') === 'true'
|
||||||
|
&& $this->activeProvider() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cleanLyricsEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decorateEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected provider config, or null if none. */
|
||||||
|
public function activeProvider(): ?array
|
||||||
|
{
|
||||||
|
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
|
||||||
|
if (! $providers) return null;
|
||||||
|
$activeId = (string) Setting::get('llm_active_id', '');
|
||||||
|
foreach ($providers as $p) {
|
||||||
|
if (($p['id'] ?? null) === $activeId) {
|
||||||
|
$kind = $p['kind'] ?? 'ollama';
|
||||||
|
// An Ollama provider doesn't need a key; the others do.
|
||||||
|
if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null;
|
||||||
|
if (trim((string) ($p['model'] ?? '')) === '') return null;
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns clean lyric lines extracted by the LLM, or [] on any failure. */
|
||||||
|
public function cleanDescription(?string $description): array
|
||||||
|
{
|
||||||
|
if (! $description || ! $this->cleanLyricsEnabled()) return [];
|
||||||
|
|
||||||
|
$provider = $this->activeProvider();
|
||||||
|
// v2 cache key: the prompt was rewritten to stop dropping English/Thai
|
||||||
|
// lyric lines that happened to carry leading/trailing emoji decoration.
|
||||||
|
$cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description);
|
||||||
|
return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) {
|
||||||
|
$prompt = "Extract the SUNG lyric lines from this song description, preserving every\n"
|
||||||
|
. "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n"
|
||||||
|
. "and Thai, English and Italian, English and Arabic) — KEEP EVERY LANGUAGE.\n\n"
|
||||||
|
. "KEEP a line when it contains real lyric words, even if it's wrapped in or\n"
|
||||||
|
. " punctuated by emojis. Example: '🛡️💻 Met behind the firewalls 🌌' → KEEP.\n"
|
||||||
|
. " Strip ONLY the emojis themselves; the lyric words stay untouched.\n"
|
||||||
|
. "DROP a line ONLY when it is one of:\n"
|
||||||
|
. " • the song title or artist credit\n"
|
||||||
|
. " • a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n"
|
||||||
|
. " Pre-Chorus / Outro / Intro / 副歌 / 후렴 / كورس / ท่อน / etc.) — typically\n"
|
||||||
|
. " one or two words, possibly numbered\n"
|
||||||
|
. " • an instrument or production note inside 【…】 or 〔…〕 brackets\n"
|
||||||
|
. " • a row that is ONLY emojis / separators / decorative symbols with no words\n"
|
||||||
|
. " • commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n"
|
||||||
|
. "Hard rules:\n"
|
||||||
|
. " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n"
|
||||||
|
. " English to Thai). The output of each kept line must be in the SAME language\n"
|
||||||
|
. " and script as the original line.\n"
|
||||||
|
. " - DO NOT merge or split lines. One source lyric line → one output entry.\n"
|
||||||
|
. " - Preserve original punctuation (drop only the emojis).\n"
|
||||||
|
. " - Maintain the original order.\n\n"
|
||||||
|
. "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n"
|
||||||
|
. "DESCRIPTION:\n" . $description;
|
||||||
|
|
||||||
|
$raw = $this->call($prompt, 8192);
|
||||||
|
if ($raw === '') return [];
|
||||||
|
|
||||||
|
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
|
||||||
|
$arr = json_decode($raw, true);
|
||||||
|
if (! is_array($arr)) return [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($arr as $line) {
|
||||||
|
$line = trim((string) $line);
|
||||||
|
if ($line === '') continue;
|
||||||
|
$out[] = $line;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite each lyric line with heavy, expressive emoji styling. Emojis go
|
||||||
|
* inside the line AND at the end; multiple per line where it fits. The
|
||||||
|
* original words are NEVER changed — emojis are layered on top.
|
||||||
|
*
|
||||||
|
* Returns [index => decoratedLineText]. The caller swaps line.text and
|
||||||
|
* re-distributes the word timings across the new tokens.
|
||||||
|
*/
|
||||||
|
public function decorateLines(array $lines): array
|
||||||
|
{
|
||||||
|
if (! $lines || ! $this->decorateEnabled()) return [];
|
||||||
|
|
||||||
|
$provider = $this->activeProvider();
|
||||||
|
$cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines));
|
||||||
|
return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) {
|
||||||
|
$numbered = [];
|
||||||
|
foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l;
|
||||||
|
|
||||||
|
$prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n"
|
||||||
|
. "Strict instructions:\n"
|
||||||
|
. "- Add emojis to almost every line (rich and visually striking, not minimal).\n"
|
||||||
|
. "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n"
|
||||||
|
. "- Use 2–4 emojis per line on average, more on emotional peaks.\n"
|
||||||
|
. "- Match emojis to the line's specific emotion, action, image, or vibe.\n"
|
||||||
|
. "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n"
|
||||||
|
. " • Aim for 30+ distinct emojis across the song.\n"
|
||||||
|
. " • Never reuse the same emoji on two adjacent lines.\n"
|
||||||
|
. " • Do NOT lean on the same 5–6 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
|
||||||
|
. " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
|
||||||
|
. " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
|
||||||
|
. "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
|
||||||
|
. " re-spelling, no script conversion. Preserve every original word verbatim.\n"
|
||||||
|
. "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
|
||||||
|
. " designed lyric post or viral TikTok caption.\n"
|
||||||
|
. "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
|
||||||
|
. " must map to exactly one output line, in the same order, with the same words.\n\n"
|
||||||
|
. "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
|
||||||
|
. "decorated line text. No prose, no markdown, no code fence.\n\n"
|
||||||
|
. "LINES:\n" . implode("\n", $numbered);
|
||||||
|
|
||||||
|
$raw = $this->call($prompt, 8192);
|
||||||
|
if ($raw === '') return [];
|
||||||
|
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
|
||||||
|
$obj = json_decode($raw, true);
|
||||||
|
if (! is_array($obj)) return [];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($obj as $k => $v) {
|
||||||
|
if (! is_string($v)) continue;
|
||||||
|
$v = trim($v);
|
||||||
|
if ($v === '') continue;
|
||||||
|
$idx = ((int) $k) - 1;
|
||||||
|
if ($idx < 0 || ! isset($lines[$idx])) continue;
|
||||||
|
// Cheap safety check: the original words must survive verbatim
|
||||||
|
// (the LLM should only LAYER emojis on top). Drop the
|
||||||
|
// decoration if too many original characters are missing.
|
||||||
|
if (! self::preservesOriginal($lines[$idx], $v)) continue;
|
||||||
|
$out[$idx] = $v;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the decorated line still contains every alphanumeric character of
|
||||||
|
* the original (in the same order). Stops the LLM from quietly rewording
|
||||||
|
* a line — we keep only decorations that strictly add emojis on top.
|
||||||
|
*/
|
||||||
|
private static function preservesOriginal(string $orig, string $decorated): bool
|
||||||
|
{
|
||||||
|
$strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||||
|
$a = $strip($orig);
|
||||||
|
$b = $strip($decorated);
|
||||||
|
if ($a === '') return true;
|
||||||
|
// Sequential subsequence check: every char of $a must appear in $b in order.
|
||||||
|
$aLen = mb_strlen($a); $bLen = mb_strlen($b);
|
||||||
|
$j = 0;
|
||||||
|
for ($i = 0; $i < $aLen; $i++) {
|
||||||
|
$needle = mb_substr($a, $i, 1);
|
||||||
|
$found = false;
|
||||||
|
for (; $j < $bLen; $j++) {
|
||||||
|
if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
|
||||||
|
}
|
||||||
|
if (! $found) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 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'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Support/EmailThumbnail.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
app/Support/HtmlSanitizer.php
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
<?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| |<br\s*/?>)*</\1>#i', '', $html);
|
||||||
|
return trim((string) $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/Support/LyricsAss.php
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
|
||||||
|
* karaoke timing from a lyrics JSON payload (the shape produced by
|
||||||
|
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
|
||||||
|
*
|
||||||
|
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
|
||||||
|
* time it waits before the highlight reaches it, so the cumulative \k before a
|
||||||
|
* word equals its onset relative to the line start. PrimaryColour is the sung
|
||||||
|
* (filled) colour, SecondaryColour the not-yet-sung colour.
|
||||||
|
*/
|
||||||
|
class LyricsAss
|
||||||
|
{
|
||||||
|
/** Render at the same canvas the slideshow uses. */
|
||||||
|
private const W = 1280;
|
||||||
|
private const H = 720;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an .ass file for the given lyrics data. Returns true on success,
|
||||||
|
* false when there are no usable timed lines (caller should skip burning).
|
||||||
|
*/
|
||||||
|
public static function write(array $lyrics, string $outPath): bool
|
||||||
|
{
|
||||||
|
$lines = $lyrics['lines'] ?? [];
|
||||||
|
if (! is_array($lines) || ! $lines) return false;
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
foreach ($lines as $ln) {
|
||||||
|
$start = $ln['start'] ?? null;
|
||||||
|
$end = $ln['end'] ?? null;
|
||||||
|
if ($start === null || $end === null) continue;
|
||||||
|
|
||||||
|
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
|
||||||
|
$text = $words
|
||||||
|
? self::karaokeText($words, (float) $start)
|
||||||
|
: self::escape((string) ($ln['text'] ?? ''));
|
||||||
|
if ($text === '') continue;
|
||||||
|
|
||||||
|
// Hold the line a touch past its last word for readability.
|
||||||
|
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
|
||||||
|
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
|
||||||
|
. ',Lyrics,,0,0,,' . $text;
|
||||||
|
}
|
||||||
|
if (! $events) return false;
|
||||||
|
|
||||||
|
$ass = self::header() . implode("\n", $events) . "\n";
|
||||||
|
return file_put_contents($outPath, $ass) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function karaokeText(array $words, float $lineStart): string
|
||||||
|
{
|
||||||
|
$out = '';
|
||||||
|
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
|
||||||
|
if ($lead > 0) $out .= '{\k' . $lead . '}';
|
||||||
|
|
||||||
|
$n = count($words);
|
||||||
|
foreach ($words as $i => $w) {
|
||||||
|
$wStart = (float) $w['start'];
|
||||||
|
$wEnd = (float) $w['end'];
|
||||||
|
$dur = max(1, (int) round(($wEnd - $wStart) * 100));
|
||||||
|
$out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
|
||||||
|
|
||||||
|
if ($i < $n - 1) {
|
||||||
|
$gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
|
||||||
|
$out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ASS timestamp: H:MM:SS.cc (centiseconds). */
|
||||||
|
private static function ts(float $t): string
|
||||||
|
{
|
||||||
|
if ($t < 0) $t = 0;
|
||||||
|
$cs = (int) round($t * 100);
|
||||||
|
$h = intdiv($cs, 360000);
|
||||||
|
$m = intdiv($cs % 360000, 6000);
|
||||||
|
$s = intdiv($cs % 6000, 100);
|
||||||
|
$c = $cs % 100;
|
||||||
|
return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escape(string $s): string
|
||||||
|
{
|
||||||
|
// Strip ASS override delimiters and collapse newlines.
|
||||||
|
$s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
|
||||||
|
return str_replace(["\r\n", "\n", "\r"], ' ', $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function header(): string
|
||||||
|
{
|
||||||
|
// Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
|
||||||
|
// (translucent grey), heavy outline + shadow for legibility over artwork.
|
||||||
|
return "[Script Info]\n"
|
||||||
|
. "ScriptType: v4.00+\n"
|
||||||
|
. 'PlayResX: ' . self::W . "\n"
|
||||||
|
. 'PlayResY: ' . self::H . "\n"
|
||||||
|
. "WrapStyle: 0\n"
|
||||||
|
. "ScaledBorderAndShadow: yes\n\n"
|
||||||
|
. "[V4+ Styles]\n"
|
||||||
|
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||||
|
. "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
|
||||||
|
. "[Events]\n"
|
||||||
|
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Support/LyricsDescriptionParser.php
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract clean lyric lines from a video's free-text description.
|
||||||
|
*
|
||||||
|
* Many users paste the song's lyrics into the description with section markers,
|
||||||
|
* emoji decorations, instrument tags, etc. When that's present we want to USE
|
||||||
|
* those exact lines (and let the pipeline only do the *sync*, not the
|
||||||
|
* transcription), because they're far more accurate than anything Whisper can
|
||||||
|
* derive from sung audio.
|
||||||
|
*
|
||||||
|
* Returns an empty array when no usable lyric block is found.
|
||||||
|
*/
|
||||||
|
class LyricsDescriptionParser
|
||||||
|
{
|
||||||
|
/** Heuristic threshold: descriptions with fewer cleaned lines aren't worth aligning. */
|
||||||
|
private const MIN_LYRIC_LINES = 4;
|
||||||
|
|
||||||
|
public static function extract(?string $desc): array
|
||||||
|
{
|
||||||
|
if (! $desc) return [];
|
||||||
|
|
||||||
|
// HTML descriptions use <br> for line breaks — convert those (and other
|
||||||
|
// block-ending tags) into real newlines BEFORE stripping tags, otherwise
|
||||||
|
// the entire body collapses into one long run-on line.
|
||||||
|
// Heading tags (<h1>…<h6>) carry the song title — drop their content
|
||||||
|
// entirely so the title never leaks into the lyric list.
|
||||||
|
$text = preg_replace('/<\s*h[1-6][^>]*>.*?<\s*\/\s*h[1-6]\s*>/isu', "\n", $desc);
|
||||||
|
$text = preg_replace('/<\s*br\s*\/?>/i', "\n", $text);
|
||||||
|
$text = preg_replace('/<\s*\/\s*(p|div|li|tr|blockquote)\s*>/i', "\n", $text);
|
||||||
|
$text = strip_tags($text);
|
||||||
|
// Decode HTML entities ( , &, etc.) so the comparison later isn't fooled.
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$text = preg_replace('/\r\n|\r/u', "\n", $text);
|
||||||
|
|
||||||
|
// First pass: clean each line and flag section headers (Verse / Ritornello
|
||||||
|
// / Bridge / etc.) so they can be dropped — those aren't sung.
|
||||||
|
$cleaned = [];
|
||||||
|
foreach (explode("\n", $text) as $line) {
|
||||||
|
$line = self::cleanLine($line);
|
||||||
|
if ($line === null) continue;
|
||||||
|
$cleaned[] = ['text' => $line, 'header' => self::isSectionHeader($line)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title detection: if the first non-header line is immediately followed by
|
||||||
|
// a section header (e.g. "Figlio Mio — Viaggio di Vita" then "Verso 1"),
|
||||||
|
// that first line is the song title — drop it too.
|
||||||
|
$firstIdx = null;
|
||||||
|
foreach ($cleaned as $i => $c) {
|
||||||
|
if (! $c['header']) { $firstIdx = $i; break; }
|
||||||
|
}
|
||||||
|
$dropTitle = false;
|
||||||
|
if ($firstIdx !== null) {
|
||||||
|
for ($j = $firstIdx + 1; $j < count($cleaned); $j++) {
|
||||||
|
if ($cleaned[$j]['header']) { $dropTitle = true; break; }
|
||||||
|
break; // first thing after is a real lyric line → not a title block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($cleaned as $i => $c) {
|
||||||
|
if ($c['header']) continue;
|
||||||
|
if ($dropTitle && $i === $firstIdx) continue;
|
||||||
|
$out[] = $c['text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid mistakenly aligning non-lyric descriptions (a credit line, a URL,
|
||||||
|
// etc.). Require at least a handful of plausible lyric lines.
|
||||||
|
if (count($out) < self::MIN_LYRIC_LINES) return [];
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a line is a section marker (Verse / Chorus / Bridge / Outro /
|
||||||
|
* their many translations) rather than a sung lyric. Matches the WHOLE line
|
||||||
|
* so a real lyric containing one of these words isn't mistakenly dropped.
|
||||||
|
*/
|
||||||
|
private static function isSectionHeader(string $line): bool
|
||||||
|
{
|
||||||
|
$t = mb_strtolower(trim($line));
|
||||||
|
if ($t === '') return false;
|
||||||
|
|
||||||
|
$roots = [
|
||||||
|
'intro', 'outro', 'interlude', 'instrumental',
|
||||||
|
'verse', 'verso', 'verset', 'couplet', 'estrofa', 'strofa',
|
||||||
|
'chorus', 'ritornello', 'refrain', 'refrão', 'refrao', 'coro', 'estribillo',
|
||||||
|
'pre[\s\-]?chorus', 'pre[\s\-]?ritornello', 'pre[\s\-]?coro',
|
||||||
|
'pre[\s\-]?refrain', 'pre[\s\-]?refrão', 'pré[\s\-]?refrain',
|
||||||
|
'bridge', 'ponte', 'puente', 'pont', 'brücke', 'brucke',
|
||||||
|
'hook', 'drop', 'breakdown', 'tag', 'vamp', 'coda', 'reprise',
|
||||||
|
// CJK / Thai / Arabic / Korean
|
||||||
|
'サビ', 'コーラス', 'バース', 'ブリッジ', 'イントロ', 'アウトロ', 'フック', '間奏',
|
||||||
|
'ท่อน', 'คอรัส', 'ฮุก', 'บริดจ์', 'อินโทร', 'เอาท์โทร',
|
||||||
|
'前奏', '副歌', '桥段', '主歌', '尾奏',
|
||||||
|
'كورس', 'بريدج', 'كوبليه',
|
||||||
|
'후렴', '브릿지', '인트로', '아웃트로', '훅',
|
||||||
|
];
|
||||||
|
$rootRe = implode('|', $roots);
|
||||||
|
// Optional trailing number, "final/finale/reprise", roman numerals.
|
||||||
|
$pattern = '/^(?:' . $rootRe . ')[\s\d:\-—\.]*(?:final|finale|reprise|ii|iii|iv|v|vi|2|3|4|5)?\s*$/iu';
|
||||||
|
return (bool) preg_match($pattern, $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the cleaned line, or null if it should be discarded. */
|
||||||
|
private static function cleanLine(string $line): ?string
|
||||||
|
{
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') return null;
|
||||||
|
|
||||||
|
// Strip markdown emphasis (* _ ~) and leading list bullets / quote markers.
|
||||||
|
$line = preg_replace('/^[\s>\-\*•♪♫·]+/u', '', $line);
|
||||||
|
$line = preg_replace('/[\*_~`]+/u', '', $line);
|
||||||
|
|
||||||
|
// Drop instrument / section annotations inside Japanese-style brackets:
|
||||||
|
// 【 箏・尺八・篠笛・優しい歌声 】 — these aren't lyrics.
|
||||||
|
$line = preg_replace('/【[^】]*】/u', '', $line);
|
||||||
|
$line = preg_replace('/〔[^〕]*〕/u', '', $line);
|
||||||
|
$line = preg_replace('/\[\[[^\]]*\]\]/u', '', $line);
|
||||||
|
|
||||||
|
// Strip emoji / pictographic symbols and the invisible glue that often
|
||||||
|
// sticks to them (variation selectors, ZWJ) so nothing leaves behind a
|
||||||
|
// bare diacritic when the visible emoji is removed.
|
||||||
|
$line = preg_replace(
|
||||||
|
'/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{0F3A}-\x{0F3D}\x{FE00}-\x{FE0F}\x{200B}-\x{200F}\x{2060}]/u',
|
||||||
|
'', $line
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapse internal whitespace.
|
||||||
|
$line = preg_replace('/\s+/u', ' ', $line);
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
if ($line === '') return null;
|
||||||
|
|
||||||
|
// Must contain at least one letter (Unicode), and at least 3 characters
|
||||||
|
// after stripping — discards "🌸 平穏 🌸" (header) and "──" separators.
|
||||||
|
if (! preg_match('/\p{L}/u', $line)) return null;
|
||||||
|
if (mb_strlen($line) < 3) return null;
|
||||||
|
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,14 @@ $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
|
||||||
|
|||||||
19
composer.lock
generated
@ -2808,18 +2808,21 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/itsp7h/File-Structure-package.git",
|
"url": "https://github.com/itsp7h/File-Structure-package.git",
|
||||||
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47"
|
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/9018271e2b73099730328191c8a4a3f2606ddc47",
|
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||||
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47",
|
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/composer": "^2.0"
|
||||||
|
},
|
||||||
"default-branch": true,
|
"default-branch": true,
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@ -2834,6 +2837,14 @@
|
|||||||
"P7H\\NasFileManager\\": "src/"
|
"P7H\\NasFileManager\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-install-cmd": [
|
||||||
|
"P7H\\NasFileManager\\Installer::install"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"P7H\\NasFileManager\\Installer::install"
|
||||||
|
]
|
||||||
|
},
|
||||||
"license": [
|
"license": [
|
||||||
"MIT"
|
"MIT"
|
||||||
],
|
],
|
||||||
@ -2842,7 +2853,7 @@
|
|||||||
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
|
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
|
||||||
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
|
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
|
||||||
},
|
},
|
||||||
"time": "2026-05-13T10:39:12+00:00"
|
"time": "2026-05-14T12:07:04+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "paragonie/constant_time_encoding",
|
"name": "paragonie/constant_time_encoding",
|
||||||
|
|||||||
@ -49,9 +49,32 @@ return [
|
|||||||
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
|
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
|
||||||
*/
|
*/
|
||||||
'schema' => [
|
'schema' => [
|
||||||
// Example — uncomment and adapt:
|
|
||||||
// ['depth' => 0, 'label' => 'Media', 'path' => 'Media', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
|
// ── users ─────────────────────────────────────────────────────────────
|
||||||
// ['depth' => 1, 'label' => 'Outlets', 'path' => 'Media/Outlets', 'parent_path' => 'Media', 'is_template' => false, 'can_edit' => true],
|
['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],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->json('notification_preferences')->nullable()->after('banner');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('notification_preferences');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('language', 10);
|
||||||
|
$table->string('label', 100);
|
||||||
|
$table->string('path', 500);
|
||||||
|
$table->string('filename', 255);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('video_audio_tracks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('videos', function (Blueprint $table) {
|
||||||
|
$table->string('language', 10)->nullable()->after('type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('videos', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('language');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?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('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->text('description')->nullable()->after('label');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('description');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->string('title')->nullable()->after('label');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('title');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<?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('sports_matches', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// A match always belongs to a video (type=match) and its creator.
|
||||||
|
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
// draft | published — lets uploaders save basic info and finish later.
|
||||||
|
$table->string('status', 20)->default('draft')->index();
|
||||||
|
|
||||||
|
// ── Basic fields (shown first; only title is required for a draft) ───
|
||||||
|
$table->string('title'); // match title (required)
|
||||||
|
$table->string('event_name')->nullable();
|
||||||
|
$table->date('match_date')->nullable();
|
||||||
|
$table->time('match_time')->nullable();
|
||||||
|
$table->string('participant1_name')->nullable();
|
||||||
|
$table->string('participant2_name')->nullable();
|
||||||
|
$table->string('referee_name')->nullable();
|
||||||
|
// ── Fields revealed later when editing ──────────────────────────────
|
||||||
|
$table->string('sport', 80)->nullable()->index();
|
||||||
|
$table->string('match_type', 80)->nullable();
|
||||||
|
$table->string('venue_name')->nullable();
|
||||||
|
|
||||||
|
// ── Optional groups (progressive disclosure) stored as JSON ──────────
|
||||||
|
$table->json('competition')->nullable(); // name, type, stage, season, organizer, championship_name
|
||||||
|
$table->json('participants')->nullable(); // p1_*/p2_* details, weight_class, gender_division, level, extra[]
|
||||||
|
$table->json('media')->nullable(); // image paths + caption, alt, credit, public
|
||||||
|
$table->json('officials')->nullable(); // [{ role, name, photo }]
|
||||||
|
$table->json('venue')->nullable(); // address, city, country, lat, lng, notes
|
||||||
|
$table->json('result')->nullable(); // winner, outcome_type, final_result, rank, notes
|
||||||
|
$table->json('segments')->nullable(); // [{ type, number, score, winner, notes }]
|
||||||
|
$table->json('statistics')->nullable(); // [{ name, value, owner, notes }]
|
||||||
|
$table->json('reviews')->nullable(); // review_type, requested_by, review_result, source_url, verification_notes, admin_notes
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sports_matches');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->string('user_agent', 512)->nullable()->after('country_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('user_agent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->string('device_id', 64)->nullable()->after('user_agent');
|
||||||
|
$table->index(['video_id', 'device_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['video_id', 'device_id']);
|
||||||
|
$table->dropColumn('device_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_downloads', function (Blueprint $table) {
|
||||||
|
$table->string('user_agent', 512)->nullable()->after('country_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('share_accesses', function (Blueprint $table) {
|
||||||
|
$table->string('user_agent', 512)->nullable()->after('country_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_downloads', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('user_agent');
|
||||||
|
});
|
||||||
|
Schema::table('share_accesses', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('user_agent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->string('device_hash', 64)->nullable()->after('device_id');
|
||||||
|
$table->index(['video_id', 'device_hash']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('video_downloads', function (Blueprint $table) {
|
||||||
|
$table->string('device_hash', 64)->nullable()->after('user_agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('share_accesses', function (Blueprint $table) {
|
||||||
|
$table->string('device_hash', 64)->nullable()->after('user_agent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['video_id', 'device_hash']);
|
||||||
|
$table->dropColumn('device_hash');
|
||||||
|
});
|
||||||
|
Schema::table('video_downloads', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('device_hash');
|
||||||
|
});
|
||||||
|
Schema::table('share_accesses', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('device_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('watched_seconds')->default(0)->after('country_name');
|
||||||
|
$table->boolean('completed')->default(false)->after('watched_seconds');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_views', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['watched_seconds', 'completed']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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('profile_visits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('profile_user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('visitor_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('device_id', 64)->nullable();
|
||||||
|
$table->foreignId('source_video_id')->nullable()->constrained('videos')->nullOnDelete();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('country', 2)->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['profile_user_id', 'created_at']);
|
||||||
|
$table->index('source_video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('profile_visits');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_subscriptions', function (Blueprint $table) {
|
||||||
|
$table->foreignId('source_video_id')->nullable()->after('channel_id')
|
||||||
|
->constrained('videos')->nullOnDelete();
|
||||||
|
$table->index('source_video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_subscriptions', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['source_video_id']);
|
||||||
|
$table->dropIndex(['source_video_id']);
|
||||||
|
$table->dropColumn('source_video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_slides', function (Blueprint $table) {
|
||||||
|
// NULL = song-wide (legacy / shared). Non-null = owned by that audio track.
|
||||||
|
// On track delete, slides become song-wide rather than disappear.
|
||||||
|
$table->foreignId('audio_track_id')
|
||||||
|
->nullable()
|
||||||
|
->after('video_id')
|
||||||
|
->constrained('video_audio_tracks')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->index(['video_id', 'audio_track_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_slides', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['video_id', 'audio_track_id']);
|
||||||
|
$table->dropConstrainedForeignId('audio_track_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add playlist view tracking — mirrors the video_views pattern.
|
||||||
|
*
|
||||||
|
* • playlists.view_count — denormalised total for fast card rendering
|
||||||
|
* • playlist_views table — one row per (playlist, viewer-id) so a refresh
|
||||||
|
* within the dedup window doesn't double-count
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('playlists', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('view_count')->default(0)->after('share_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('playlist_views', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('playlist_id');
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('device_id', 64)->nullable();
|
||||||
|
$table->string('device_hash', 64)->nullable();
|
||||||
|
$table->string('ip_address', 64)->nullable();
|
||||||
|
$table->string('country', 8)->nullable();
|
||||||
|
$table->string('country_name', 64)->nullable();
|
||||||
|
$table->string('user_agent', 512)->nullable();
|
||||||
|
$table->timestamp('viewed_at');
|
||||||
|
|
||||||
|
$table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
|
||||||
|
$table->index(['playlist_id', 'viewed_at']);
|
||||||
|
$table->index(['playlist_id', 'user_id', 'viewed_at']);
|
||||||
|
$table->index(['playlist_id', 'device_id', 'viewed_at']);
|
||||||
|
$table->index(['playlist_id', 'device_hash', 'viewed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('playlist_views');
|
||||||
|
Schema::table('playlists', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('view_count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
895
ml/transcribe.py
Normal file
@ -0,0 +1,895 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Lyrics transcription + word-level alignment pipeline.
|
||||||
|
|
||||||
|
Pipeline: Demucs (isolate vocals) -> WhisperX transcribe (large-v3) -> forced
|
||||||
|
word alignment. Emits a JSON file with line- and word-level timestamps that the
|
||||||
|
web player overlay and the ASS subtitle burner both consume.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
transcribe.py --audio /abs/song.mp3 --out /abs/lyrics.json \
|
||||||
|
[--language en] [--gpu 0] [--model large-v3] [--no-demucs]
|
||||||
|
|
||||||
|
All heavy logs go to stderr; stdout stays clean. Exit code 0 on success.
|
||||||
|
The output JSON shape is:
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"language": "en",
|
||||||
|
"source": "whisperx",
|
||||||
|
"model": "large-v3",
|
||||||
|
"demucs": true,
|
||||||
|
"lines": [
|
||||||
|
{"start": 12.30, "end": 16.80, "text": "...",
|
||||||
|
"words": [{"start": 12.30, "end": 12.55, "text": "..."}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def log(*a):
|
||||||
|
print(*a, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Progress file path, set from --progress. The web layer polls a status endpoint
|
||||||
|
# that reads this file to drive a live progress bar.
|
||||||
|
_PROGRESS_PATH = None
|
||||||
|
|
||||||
|
|
||||||
|
def write_progress(pct: int, stage: str):
|
||||||
|
if not _PROGRESS_PATH:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = _PROGRESS_PATH + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"status": "processing", "pct": int(pct), "stage": stage}, f)
|
||||||
|
os.replace(tmp, _PROGRESS_PATH)
|
||||||
|
except Exception:
|
||||||
|
pass # progress is best-effort, never fail the run over it
|
||||||
|
|
||||||
|
|
||||||
|
def isolate_vocals(audio_path: str, gpu: int | None) -> str | None:
|
||||||
|
"""Run Demucs two-stem separation and return the path to vocals.wav.
|
||||||
|
|
||||||
|
Returns None if separation fails so the caller can fall back to the raw mix.
|
||||||
|
"""
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="demucs_")
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "demucs",
|
||||||
|
"--two-stems", "vocals",
|
||||||
|
"-n", "htdemucs",
|
||||||
|
"-o", tmp_dir,
|
||||||
|
audio_path,
|
||||||
|
]
|
||||||
|
env = dict(os.environ)
|
||||||
|
if gpu is not None:
|
||||||
|
env["CUDA_VISIBLE_DEVICES"] = str(gpu)
|
||||||
|
cmd += ["-d", "cuda"]
|
||||||
|
else:
|
||||||
|
cmd += ["-d", "cpu"]
|
||||||
|
|
||||||
|
log(f"[demucs] separating vocals -> {tmp_dir}")
|
||||||
|
try:
|
||||||
|
# Stream stderr so demucs' tqdm percentage drives live progress (8→38%).
|
||||||
|
import re
|
||||||
|
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE, bufsize=0)
|
||||||
|
buf = b""
|
||||||
|
last = -1
|
||||||
|
while True:
|
||||||
|
chunk = proc.stderr.read(64)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
# tqdm overwrites with \r; scan the tail for the newest "NN%".
|
||||||
|
text = buf[-200:].decode("utf-8", "ignore")
|
||||||
|
m = re.findall(r"(\d{1,3})%", text)
|
||||||
|
if m:
|
||||||
|
p = int(m[-1])
|
||||||
|
if 0 <= p <= 100 and p != last:
|
||||||
|
last = p
|
||||||
|
write_progress(8 + int(p * 0.30), "Separating vocals")
|
||||||
|
proc.wait()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log(f"[demucs] exited {proc.returncode}; falling back to raw mix")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[demucs] failed ({e}); falling back to raw mix")
|
||||||
|
return None
|
||||||
|
|
||||||
|
stem = Path(audio_path).stem
|
||||||
|
vocals = Path(tmp_dir) / "htdemucs" / stem / "vocals.wav"
|
||||||
|
if vocals.exists():
|
||||||
|
log(f"[demucs] vocals at {vocals}")
|
||||||
|
return str(vocals)
|
||||||
|
log("[demucs] vocals.wav not found; falling back to raw mix")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Karaoke display lines are short — we re-split a segment's words on natural
|
||||||
|
# pauses, a soft word cap, and (for spaced scripts) clause punctuation / new
|
||||||
|
# capitalised lines.
|
||||||
|
LINE_GAP = 0.65 # seconds of silence that ends a display line
|
||||||
|
LINE_MAX_WORDS = 12 # hard cap so Latin-script lines never overflow
|
||||||
|
LINE_MAX_CHARS = 30 # char cap for spaceless scripts (Thai/CJK/…)
|
||||||
|
LINE_MIN_WORDS = 3 # don't break on punctuation before this many words
|
||||||
|
PUNCT_END = (".", ",", "!", "?", ";", ":", "—")
|
||||||
|
# Scripts written without spaces between words — join tokens directly and split
|
||||||
|
# by character count instead of word count.
|
||||||
|
SPACELESS = {"th", "zh", "ja", "lo", "my", "km", "yue", "wuu"}
|
||||||
|
# Languages that use a non-Latin script — used to detect a mis-forced pass (a
|
||||||
|
# Thai/Arabic/… pass that produced Latin text is really a misheard English part).
|
||||||
|
NONLATIN_LANGS = {
|
||||||
|
"th", "zh", "ja", "ko", "ar", "he", "ru", "uk", "bg", "sr", "mk", "el",
|
||||||
|
"hi", "bn", "ta", "te", "kn", "ml", "mr", "ne", "si", "my", "km", "lo",
|
||||||
|
"ka", "am", "fa", "ur", "ps", "yue", "wuu", "yi",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(words: list, lang: str) -> dict | None:
|
||||||
|
if not words:
|
||||||
|
return None
|
||||||
|
sep = "" if lang in SPACELESS else " "
|
||||||
|
return {
|
||||||
|
"start": words[0]["start"],
|
||||||
|
"end": words[-1]["end"],
|
||||||
|
"text": sep.join(w["text"] for w in words),
|
||||||
|
"lang": lang,
|
||||||
|
"words": words,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_for_match(s: str) -> str:
|
||||||
|
"""Normalize text for similarity comparison (lowercase, keep letters/numbers
|
||||||
|
including non-ASCII scripts; drop everything else)."""
|
||||||
|
out = []
|
||||||
|
for c in s or "":
|
||||||
|
if c.isalnum():
|
||||||
|
out.append(c.lower())
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_lang_from_script(text: str) -> str:
|
||||||
|
"""Best-effort language guess from a line's Unicode script (used when we have
|
||||||
|
no whisper anchor to inherit the language from)."""
|
||||||
|
for c in text or "":
|
||||||
|
co = ord(c)
|
||||||
|
if 0x3040 <= co <= 0x30FF or 0x4E00 <= co <= 0x9FFF:
|
||||||
|
return "ja"
|
||||||
|
if 0x0E00 <= co <= 0x0E7F:
|
||||||
|
return "th"
|
||||||
|
if 0xAC00 <= co <= 0xD7AF:
|
||||||
|
return "ko"
|
||||||
|
if 0x0600 <= co <= 0x06FF:
|
||||||
|
return "ar"
|
||||||
|
if 0x0400 <= co <= 0x04FF:
|
||||||
|
return "ru"
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
|
||||||
|
def _redistribute_words(start: float, end: float, text: str, lang: str) -> list:
|
||||||
|
"""Evenly distribute the line's [start,end] across its tokens — words for
|
||||||
|
spaced languages, characters for spaceless scripts (Thai/CJK/…)."""
|
||||||
|
if not text or end <= start:
|
||||||
|
return []
|
||||||
|
tokens = list(text) if lang in SPACELESS else text.split()
|
||||||
|
tokens = [t for t in tokens if t.strip()]
|
||||||
|
n = len(tokens)
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
slot = (end - start) / n
|
||||||
|
return [{"start": round(start + i * slot, 3),
|
||||||
|
"end": round(start + (i + 1) * slot, 3),
|
||||||
|
"text": t} for i, t in enumerate(tokens)]
|
||||||
|
|
||||||
|
|
||||||
|
def _distribute_in_vocal_regions(lines: list, regions: list,
|
||||||
|
gap_start: float, gap_end: float) -> list:
|
||||||
|
"""Place each line at a moment within [gap_start, gap_end] where vocals
|
||||||
|
are actually active. `regions` is a list of (start, end) seconds covering
|
||||||
|
the whole song. Falls back to even spread if no vocal activity is detected
|
||||||
|
in the gap (e.g. instrumental break with no vocals at all)."""
|
||||||
|
gap_regions = []
|
||||||
|
for s, e in regions:
|
||||||
|
s_clip = max(s, gap_start)
|
||||||
|
e_clip = min(e, gap_end)
|
||||||
|
if e_clip - s_clip >= 0.3:
|
||||||
|
gap_regions.append((s_clip, e_clip))
|
||||||
|
|
||||||
|
N = len(lines)
|
||||||
|
if N == 0: return []
|
||||||
|
if not gap_regions or gap_end <= gap_start:
|
||||||
|
# No vocals in the gap — last-resort even spread so coverage isn't lost.
|
||||||
|
if gap_end <= gap_start: return []
|
||||||
|
slot = (gap_end - gap_start) / N
|
||||||
|
out = []
|
||||||
|
for k, ul in enumerate(lines):
|
||||||
|
s = gap_start + k * slot
|
||||||
|
e = gap_start + (k + 1) * slot
|
||||||
|
lang = _guess_lang_from_script(ul)
|
||||||
|
out.append({"start": round(s, 3), "end": round(e, 3),
|
||||||
|
"text": ul, "lang": lang,
|
||||||
|
"words": _redistribute_words(s, e, ul, lang)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
M = len(gap_regions)
|
||||||
|
out = []
|
||||||
|
|
||||||
|
if N <= M:
|
||||||
|
# Fewer lines than vocal regions — pick N regions roughly evenly spaced
|
||||||
|
# and start each line at its region's start. Each line ends at the next
|
||||||
|
# selected region's start (or its own region's end if last).
|
||||||
|
chosen = [int(round(i * (M - 1) / max(1, N - 1))) if N > 1 else 0 for i in range(N)]
|
||||||
|
# Ensure strictly increasing
|
||||||
|
for i in range(1, len(chosen)):
|
||||||
|
if chosen[i] <= chosen[i - 1]:
|
||||||
|
chosen[i] = min(M - 1, chosen[i - 1] + 1)
|
||||||
|
for i, ul in enumerate(lines):
|
||||||
|
rs, re = gap_regions[chosen[i]]
|
||||||
|
if i + 1 < N:
|
||||||
|
nxt_rs = gap_regions[chosen[i + 1]][0]
|
||||||
|
line_end = min(re, nxt_rs - 0.05)
|
||||||
|
else:
|
||||||
|
line_end = re
|
||||||
|
line_end = max(rs + 0.4, line_end)
|
||||||
|
lang = _guess_lang_from_script(ul)
|
||||||
|
out.append({"start": round(rs, 3), "end": round(line_end, 3),
|
||||||
|
"text": ul, "lang": lang,
|
||||||
|
"words": _redistribute_words(rs, line_end, ul, lang)})
|
||||||
|
else:
|
||||||
|
# More lines than vocal regions — assign multiple lines per region,
|
||||||
|
# divided proportionally to each region's duration so longer regions
|
||||||
|
# take more lines.
|
||||||
|
total = sum(e - s for s, e in gap_regions)
|
||||||
|
line_idx = 0
|
||||||
|
consumed = 0.0
|
||||||
|
for ri, (rs, re) in enumerate(gap_regions):
|
||||||
|
# Lines that should land in this region: proportional to its share
|
||||||
|
# of total vocal time, rounded so the last region takes the rest.
|
||||||
|
if ri == M - 1:
|
||||||
|
n_here = N - line_idx
|
||||||
|
else:
|
||||||
|
consumed += re - rs
|
||||||
|
target = int(round(consumed / total * N))
|
||||||
|
n_here = max(0, target - line_idx)
|
||||||
|
if n_here <= 0: continue
|
||||||
|
slot = (re - rs) / n_here
|
||||||
|
for k in range(n_here):
|
||||||
|
if line_idx >= N: break
|
||||||
|
s = rs + k * slot
|
||||||
|
e = rs + (k + 1) * slot
|
||||||
|
ul = lines[line_idx]
|
||||||
|
lang = _guess_lang_from_script(ul)
|
||||||
|
out.append({"start": round(s, 3), "end": round(e, 3),
|
||||||
|
"text": ul, "lang": lang,
|
||||||
|
"words": _redistribute_words(s, e, ul, lang)})
|
||||||
|
line_idx += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def correct_whisper_with_description(whisper_lines: list, user_lines: list,
|
||||||
|
audio_duration: float = 0.0,
|
||||||
|
vocal_regions: list = None) -> list:
|
||||||
|
"""Description-first alignment, with whisper used only as structural anchors:
|
||||||
|
1. Find HIGH-confidence whisper-to-description matches (sim ≥ STRONG).
|
||||||
|
Weak/spurious matches are ignored — they cause downstream skips and
|
||||||
|
misplacements (e.g. line #5 anchored at 30s because of a loose match,
|
||||||
|
making line #4 disappear).
|
||||||
|
2. The strong anchors partition the description into segments. Each
|
||||||
|
segment of description lines is distributed across the vocal regions
|
||||||
|
in its time window — so every line lands on actual singing and every
|
||||||
|
line appears exactly once, in order.
|
||||||
|
3. No description line is ever skipped; no weak match consumes the wrong
|
||||||
|
slot; every output line carries description text (never whisper).
|
||||||
|
|
||||||
|
Falls back to pure vocal-region distribution if no strong anchors exist.
|
||||||
|
"""
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
if not user_lines:
|
||||||
|
return whisper_lines or []
|
||||||
|
|
||||||
|
U = [u for u in user_lines if u.strip()]
|
||||||
|
if not U:
|
||||||
|
return whisper_lines or []
|
||||||
|
|
||||||
|
vocal_regions = vocal_regions or []
|
||||||
|
audio_end = max(audio_duration, 10.0)
|
||||||
|
if vocal_regions:
|
||||||
|
audio_end = max(audio_end, vocal_regions[-1][1])
|
||||||
|
|
||||||
|
# ── Find strong anchors ────────────────────────────────────────────────
|
||||||
|
# Only matches at STRONG similarity (0.55+) count as anchors. Anything
|
||||||
|
# less confident than that has historically misled the alignment.
|
||||||
|
user_script = [_guess_lang_from_script(u) for u in U]
|
||||||
|
user_norm = [_norm_for_match(u) for u in U]
|
||||||
|
|
||||||
|
LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
|
||||||
|
def same_script(a: str, b: str) -> bool:
|
||||||
|
if a in LATIN and b in LATIN: return True
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
STRONG = 0.55
|
||||||
|
SKIP_AHEAD = 10
|
||||||
|
|
||||||
|
anchors = [] # list of (user_idx, whisper_start, whisper_end)
|
||||||
|
next_u = 0
|
||||||
|
for w in (whisper_lines or []):
|
||||||
|
w_text = (w.get("text") or "").strip()
|
||||||
|
if not w_text: continue
|
||||||
|
w_lang = w.get("lang") or _guess_lang_from_script(w_text)
|
||||||
|
w_norm = _norm_for_match(w_text)
|
||||||
|
if not w_norm: continue
|
||||||
|
best_u = -1; best_sim = 0.0
|
||||||
|
end = min(next_u + SKIP_AHEAD + 1, len(U))
|
||||||
|
for ui in range(next_u, end):
|
||||||
|
if not same_script(user_script[ui], w_lang): continue
|
||||||
|
if not user_norm[ui]: continue
|
||||||
|
sim = SequenceMatcher(None, user_norm[ui], w_norm).ratio()
|
||||||
|
if sim > best_sim:
|
||||||
|
best_sim = sim; best_u = ui
|
||||||
|
if best_u >= 0 and best_sim >= STRONG:
|
||||||
|
anchors.append((best_u, float(w["start"]), float(w["end"])))
|
||||||
|
next_u = best_u + 1
|
||||||
|
|
||||||
|
# ── Build output ───────────────────────────────────────────────────────
|
||||||
|
out = []
|
||||||
|
|
||||||
|
if not anchors:
|
||||||
|
# No reliable whisper structure — distribute all description lines
|
||||||
|
# across the vocal regions in order. Best-effort but never skips.
|
||||||
|
return _distribute_in_vocal_regions(U, vocal_regions, 0.5, audio_end - 0.3)
|
||||||
|
|
||||||
|
# Segment 0: description lines BEFORE the first anchor go in the time
|
||||||
|
# window [0, anchor[0].start], aligned to vocal regions there.
|
||||||
|
first_u, first_start, _ = anchors[0]
|
||||||
|
if first_u > 0 and first_start > 0.6:
|
||||||
|
out.extend(_distribute_in_vocal_regions(
|
||||||
|
U[0:first_u], vocal_regions, 0.0, first_start
|
||||||
|
))
|
||||||
|
|
||||||
|
# The anchor line itself uses whisper timing.
|
||||||
|
out.append(_build_line(U[first_u], first_start, anchors[0][2]))
|
||||||
|
|
||||||
|
# Middle segments: between each pair of anchors, distribute the lines
|
||||||
|
# between them across vocal regions in that window.
|
||||||
|
for i in range(1, len(anchors)):
|
||||||
|
prev_u, _, prev_end_t = anchors[i - 1]
|
||||||
|
cur_u, cur_start_t, cur_end_t = anchors[i]
|
||||||
|
gap_start = prev_end_t
|
||||||
|
gap_end = cur_start_t
|
||||||
|
between_lines = U[prev_u + 1 : cur_u]
|
||||||
|
if between_lines and gap_end - gap_start > 0.6:
|
||||||
|
out.extend(_distribute_in_vocal_regions(
|
||||||
|
between_lines, vocal_regions, gap_start, gap_end
|
||||||
|
))
|
||||||
|
out.append(_build_line(U[cur_u], cur_start_t, cur_end_t))
|
||||||
|
|
||||||
|
# Trailing segment: description lines after the last anchor distributed
|
||||||
|
# across the audio's remaining vocal regions.
|
||||||
|
last_u, _, last_end_t = anchors[-1]
|
||||||
|
trailing = U[last_u + 1:]
|
||||||
|
if trailing:
|
||||||
|
end_time = max(audio_end - 0.3, last_end_t + 2.0)
|
||||||
|
if end_time > last_end_t + 0.6:
|
||||||
|
out.extend(_distribute_in_vocal_regions(
|
||||||
|
trailing, vocal_regions, last_end_t, end_time
|
||||||
|
))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_line(text: str, start: float, end: float) -> dict:
|
||||||
|
"""Construct an output line dict with redistributed word timings."""
|
||||||
|
lang = _guess_lang_from_script(text)
|
||||||
|
s = round(float(start), 3)
|
||||||
|
e = round(max(float(end), s + 0.4), 3)
|
||||||
|
return {"start": s, "end": e, "text": text, "lang": lang,
|
||||||
|
"words": _redistribute_words(s, e, text, lang)}
|
||||||
|
|
||||||
|
|
||||||
|
def _spread_lines_evenly(lines: list, start: float, end: float) -> list:
|
||||||
|
"""Distribute `lines` evenly between [start, end]. Used as a last-resort
|
||||||
|
fallback when whisper produced no usable anchors at all."""
|
||||||
|
if not lines or end <= start: return []
|
||||||
|
slot = (end - start) / len(lines)
|
||||||
|
out = []
|
||||||
|
for k, ul in enumerate(lines):
|
||||||
|
s = start + k * slot
|
||||||
|
e = start + (k + 1) * slot
|
||||||
|
lang = _guess_lang_from_script(ul)
|
||||||
|
out.append({
|
||||||
|
"start": round(s, 3), "end": round(e, 3),
|
||||||
|
"text": ul, "lang": lang,
|
||||||
|
"words": _redistribute_words(s, e, ul, lang),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def align_user_lyrics(user_lines: list, whisper_lines: list) -> list:
|
||||||
|
"""Legacy: project user lines onto whisper anchors with N-W DP. Kept for
|
||||||
|
reference; the active pipeline uses correct_whisper_with_description()
|
||||||
|
because it preserves whisper's natural timing instead of squeezing all
|
||||||
|
description lines into whatever anchors were found."""
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
if not user_lines:
|
||||||
|
return whisper_lines
|
||||||
|
if not whisper_lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
U = [u for u in user_lines if u.strip()]
|
||||||
|
W = whisper_lines
|
||||||
|
nU, nW = len(U), len(W)
|
||||||
|
if nU == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user_norm = [_norm_for_match(u) for u in U]
|
||||||
|
whisper_norm = [_norm_for_match(w.get("text", "")) for w in W]
|
||||||
|
|
||||||
|
# Script of each user line and each whisper line. For multilingual songs
|
||||||
|
# an English user line MUST anchor to an English whisper segment and a Thai
|
||||||
|
# user line MUST anchor to a Thai whisper segment — otherwise the DP forces
|
||||||
|
# a Thai user line onto an English anchor (or vice-versa) and the whole
|
||||||
|
# block of mismatched-language user lines collapses into the wrong region.
|
||||||
|
user_script = [_guess_lang_from_script(u) for u in U]
|
||||||
|
whisper_script = [(w.get("lang") or _guess_lang_from_script(w.get("text", ""))) for w in W]
|
||||||
|
|
||||||
|
def _same_script(a: str, b: str) -> bool:
|
||||||
|
# Coarse equivalence — collapse all Latin-script European languages
|
||||||
|
# together, all CJK together, etc. so e.g. an English user line still
|
||||||
|
# matches a Spanish whisper anchor if that's all we have.
|
||||||
|
LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
|
||||||
|
if a in LATIN and b in LATIN: return True
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
# Similarity matrix (cached lookups via SequenceMatcher). Cross-script
|
||||||
|
# pairs are zeroed so the DP can never anchor across languages.
|
||||||
|
sim = [[0.0] * nW for _ in range(nU)]
|
||||||
|
for i in range(nU):
|
||||||
|
if not user_norm[i]:
|
||||||
|
continue
|
||||||
|
sm = SequenceMatcher(None, user_norm[i], "")
|
||||||
|
sm.set_seq1(user_norm[i])
|
||||||
|
for j in range(nW):
|
||||||
|
if not whisper_norm[j]:
|
||||||
|
continue
|
||||||
|
if not _same_script(user_script[i], whisper_script[j]):
|
||||||
|
continue # different script → can't be the same line
|
||||||
|
sm.set_seq2(whisper_norm[j])
|
||||||
|
sim[i][j] = sm.ratio()
|
||||||
|
|
||||||
|
# Higher threshold prevents the DP from anchoring a user line to a weakly-
|
||||||
|
# similar whisper segment in the wrong region of the song. Weak matches get
|
||||||
|
# interpolated between confident anchors instead, which spreads lyric lines
|
||||||
|
# over the right time window.
|
||||||
|
MATCH_THRESHOLD = 0.35
|
||||||
|
GAP_USER = -0.10 # cost of leaving a user line unmatched
|
||||||
|
GAP_WHISPER = -0.04 # cost of skipping a whisper line
|
||||||
|
SOFT_DIAG = -0.04 # diagonal move with too-low similarity (no match credit)
|
||||||
|
|
||||||
|
# DP table: dp[i][j] = best score aligning U[:i] vs W[:j].
|
||||||
|
dp = [[0.0] * (nW + 1) for _ in range(nU + 1)]
|
||||||
|
for i in range(1, nU + 1):
|
||||||
|
dp[i][0] = dp[i - 1][0] + GAP_USER
|
||||||
|
for j in range(1, nW + 1):
|
||||||
|
dp[0][j] = dp[0][j - 1] + GAP_WHISPER
|
||||||
|
|
||||||
|
for i in range(1, nU + 1):
|
||||||
|
for j in range(1, nW + 1):
|
||||||
|
s = sim[i - 1][j - 1]
|
||||||
|
match_score = dp[i - 1][j - 1] + (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
|
||||||
|
user_gap = dp[i - 1][j] + GAP_USER
|
||||||
|
whisper_gap = dp[i][j - 1] + GAP_WHISPER
|
||||||
|
dp[i][j] = max(match_score, user_gap, whisper_gap)
|
||||||
|
|
||||||
|
# Traceback to recover the matched pairs (user_idx → whisper_idx).
|
||||||
|
matches = {}
|
||||||
|
i, j = nU, nW
|
||||||
|
while i > 0 and j > 0:
|
||||||
|
s = sim[i - 1][j - 1]
|
||||||
|
eff = (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
|
||||||
|
if abs(dp[i][j] - (dp[i - 1][j - 1] + eff)) < 1e-9:
|
||||||
|
if s >= MATCH_THRESHOLD:
|
||||||
|
matches[i - 1] = j - 1
|
||||||
|
i -= 1; j -= 1
|
||||||
|
elif abs(dp[i][j] - (dp[i - 1][j] + GAP_USER)) < 1e-9:
|
||||||
|
i -= 1
|
||||||
|
else:
|
||||||
|
j -= 1
|
||||||
|
|
||||||
|
# Build aligned output: matched lines get the whisper timing; unmatched user
|
||||||
|
# lines get evenly interpolated between their nearest matched neighbours.
|
||||||
|
out = []
|
||||||
|
pending = []
|
||||||
|
last_end = 0.0
|
||||||
|
|
||||||
|
def flush(next_start):
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
n = len(pending)
|
||||||
|
span = max(0.0, next_start - last_end)
|
||||||
|
slot = (span / (n + 1)) if span > 0 else 0.6
|
||||||
|
for k, (pt, pl) in enumerate(pending):
|
||||||
|
s = last_end + (k + 0.5) * slot
|
||||||
|
e = last_end + (k + 1.5) * slot
|
||||||
|
out.append({"start": round(s, 3), "end": round(e, 3),
|
||||||
|
"text": pt, "lang": pl,
|
||||||
|
"words": _redistribute_words(s, e, pt, pl)})
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for ui, u in enumerate(U):
|
||||||
|
if ui in matches:
|
||||||
|
wl = W[matches[ui]]
|
||||||
|
start = float(wl["start"])
|
||||||
|
end = float(wl["end"])
|
||||||
|
lang = wl.get("lang") or _guess_lang_from_script(u)
|
||||||
|
flush(start)
|
||||||
|
out.append({"start": round(start, 3), "end": round(end, 3),
|
||||||
|
"text": u, "lang": lang,
|
||||||
|
"words": _redistribute_words(start, end, u, lang)})
|
||||||
|
last_end = end
|
||||||
|
else:
|
||||||
|
pending.append((u, _guess_lang_from_script(u)))
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
anchor_end = max(last_end + 1.0, float(W[-1]["end"]))
|
||||||
|
flush(anchor_end)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_fragments(lines: list) -> list:
|
||||||
|
"""Stitch tiny leftover fragments (e.g. a lone 'The' or a 1-char Thai token)
|
||||||
|
into an adjacent same-language line when they're close in time."""
|
||||||
|
def tiny(ln):
|
||||||
|
if ln["lang"] in SPACELESS:
|
||||||
|
return len(ln["text"]) < 4
|
||||||
|
return len(ln["text"].split()) < 2
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for ln in lines:
|
||||||
|
if out and out[-1]["lang"] == ln["lang"]:
|
||||||
|
prev = out[-1]
|
||||||
|
gap = ln["start"] - prev["end"]
|
||||||
|
if gap < 1.0 and (tiny(ln) or tiny(prev)):
|
||||||
|
sep = "" if ln["lang"] in SPACELESS else " "
|
||||||
|
prev["text"] = (prev["text"] + sep + ln["text"]).strip()
|
||||||
|
prev["end"] = ln["end"]
|
||||||
|
prev["words"] = (prev.get("words") or []) + (ln.get("words") or [])
|
||||||
|
continue
|
||||||
|
out.append(ln)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_lines(words: list, lang: str) -> list:
|
||||||
|
"""Split one (single-language) segment's timed words into short karaoke lines."""
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
spaced = lang not in SPACELESS
|
||||||
|
lines, cur = [], [words[0]]
|
||||||
|
for prev, w in zip(words, words[1:]):
|
||||||
|
brk = (w["start"] - prev["end"]) >= LINE_GAP
|
||||||
|
if not brk and spaced and len(cur) >= LINE_MAX_WORDS:
|
||||||
|
brk = True
|
||||||
|
if not brk and not spaced and sum(len(x["text"]) for x in cur) >= LINE_MAX_CHARS:
|
||||||
|
brk = True
|
||||||
|
if not brk and spaced and len(cur) >= LINE_MIN_WORDS:
|
||||||
|
if prev["text"].endswith(PUNCT_END):
|
||||||
|
brk = True
|
||||||
|
else:
|
||||||
|
head = w["text"][:1]
|
||||||
|
if (head.isupper() and not head.isdigit()
|
||||||
|
and w["text"] not in ("I", "I'm", "I'll", "I've", "I'd", "I’m", "I’ll", "I’ve", "I’d")):
|
||||||
|
brk = True
|
||||||
|
if brk:
|
||||||
|
line = _emit(cur, lang)
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
cur = [w]
|
||||||
|
else:
|
||||||
|
cur.append(w)
|
||||||
|
line = _emit(cur, lang)
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--audio", required=True)
|
||||||
|
ap.add_argument("--out", required=True)
|
||||||
|
ap.add_argument("--language", default=None)
|
||||||
|
ap.add_argument("--gpu", type=int, default=None)
|
||||||
|
ap.add_argument("--model", default="large-v3")
|
||||||
|
ap.add_argument("--no-demucs", action="store_true")
|
||||||
|
ap.add_argument("--no-vad", action="store_true",
|
||||||
|
help="disable Silero VAD filter inside Whisper (transcribe full audio)")
|
||||||
|
ap.add_argument("--no-vocal-gapfill", action="store_true",
|
||||||
|
help="distribute gap-filled description lines evenly instead of snapping them "
|
||||||
|
"to vocal-active regions detected by Silero VAD")
|
||||||
|
ap.add_argument("--progress", default=None, help="path to write live progress JSON")
|
||||||
|
ap.add_argument("--user-lyrics", default=None,
|
||||||
|
help="path to a text file with one lyric line per line; the pipeline will "
|
||||||
|
"ALIGN these exact lines to the audio instead of producing its own text")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
global _PROGRESS_PATH
|
||||||
|
_PROGRESS_PATH = args.progress
|
||||||
|
|
||||||
|
if not os.path.isfile(args.audio):
|
||||||
|
log(f"audio not found: {args.audio}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
write_progress(3, "Starting")
|
||||||
|
|
||||||
|
# GPU pinning must happen before torch is imported by whisperx.
|
||||||
|
if args.gpu is not None:
|
||||||
|
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
|
||||||
|
|
||||||
|
vocals_path = args.audio
|
||||||
|
used_demucs = False
|
||||||
|
if not args.no_demucs:
|
||||||
|
write_progress(8, "Separating vocals")
|
||||||
|
sep = isolate_vocals(args.audio, args.gpu)
|
||||||
|
if sep:
|
||||||
|
vocals_path = sep
|
||||||
|
used_demucs = True
|
||||||
|
|
||||||
|
write_progress(40, "Loading model")
|
||||||
|
from faster_whisper import WhisperModel, decode_audio
|
||||||
|
from faster_whisper.vad import get_speech_timestamps, VadOptions
|
||||||
|
from collections import defaultdict
|
||||||
|
import gc
|
||||||
|
|
||||||
|
SR = 16000
|
||||||
|
audio = decode_audio(vocals_path, sampling_rate=SR)
|
||||||
|
|
||||||
|
def is_oom(e):
|
||||||
|
s = str(e).lower()
|
||||||
|
return "out of memory" in s or "cuda failed" in s or "cublas" in s
|
||||||
|
|
||||||
|
def overlap_ratio(a, b):
|
||||||
|
o = min(a["end"], b["end"]) - max(a["start"], b["start"])
|
||||||
|
if o <= 0:
|
||||||
|
return 0.0
|
||||||
|
return o / max(1e-6, min(a["end"] - a["start"], b["end"] - b["start"]))
|
||||||
|
|
||||||
|
# Full multilingual transcription on a given device/precision. Raises on OOM
|
||||||
|
# so the caller can retry on a lighter config (cuda/fp16 → cuda/int8 → cpu).
|
||||||
|
#
|
||||||
|
# Strategy that handles bilingual duets WITHOUT skipping verses: transcribe the
|
||||||
|
# WHOLE song once per candidate language (full recall + sentence context), then
|
||||||
|
# for every time region keep whichever language's transcription is the most
|
||||||
|
# confident. English regions win in the English pass, Thai regions win in the
|
||||||
|
# Thai pass — nothing is dropped and each part is in its own script.
|
||||||
|
def transcribe_all(dev, ct):
|
||||||
|
log(f"[fw] loading {args.model} on {dev}/{ct}")
|
||||||
|
model = WhisperModel(args.model, device=dev, compute_type=ct)
|
||||||
|
try:
|
||||||
|
# ── Candidate languages: detect across several windows of the song ──
|
||||||
|
write_progress(46, "Detecting languages")
|
||||||
|
if args.language:
|
||||||
|
cands = [args.language]
|
||||||
|
else:
|
||||||
|
votes = defaultdict(float)
|
||||||
|
win = 30 * SR
|
||||||
|
positions = list(range(0, max(1, len(audio) - win + 1), max(win // 2, 1)))[:12] or [0]
|
||||||
|
for pos in positions:
|
||||||
|
sl = audio[pos:pos + win]
|
||||||
|
if len(sl) < SR:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lang, prob, _ = model.detect_language(sl, language_detection_segments=1)
|
||||||
|
except Exception as e:
|
||||||
|
if is_oom(e):
|
||||||
|
raise
|
||||||
|
lang, prob = None, 0.0
|
||||||
|
if lang and prob >= 0.5:
|
||||||
|
votes[lang] += prob
|
||||||
|
if not votes:
|
||||||
|
cands = ["en"]
|
||||||
|
else:
|
||||||
|
ranked = sorted(votes, key=votes.get, reverse=True)
|
||||||
|
top = votes[ranked[0]]
|
||||||
|
# Keep languages with ≥25% of the top vote mass (drops flukes).
|
||||||
|
cands = [l for l in ranked if votes[l] >= 0.25 * top][:3]
|
||||||
|
log(f"[lang] candidates={cands}")
|
||||||
|
|
||||||
|
# ── One full-song pass per candidate language ──────────────────────
|
||||||
|
# Loose VAD pass: drops obvious instrumental stretches but keeps soft
|
||||||
|
# sung vocals (threshold 0.20 vs default 0.5). Without it, Whisper
|
||||||
|
# invents lyrics over the intro/outro music. With it tuned too high
|
||||||
|
# it drops legitimate quiet singing — we erred on the loose side after
|
||||||
|
# users reported missing verses in the middle of long songs.
|
||||||
|
VAD_PARAMS = {
|
||||||
|
"threshold": 0.20,
|
||||||
|
"min_speech_duration_ms": 200,
|
||||||
|
"min_silence_duration_ms": 350,
|
||||||
|
"speech_pad_ms": 250,
|
||||||
|
}
|
||||||
|
# Common Whisper hallucinations on silence / music. If a segment IS
|
||||||
|
# one of these phrases (no extra content), it's a hallucination
|
||||||
|
# regardless of how confident the model was.
|
||||||
|
HALLUCINATIONS = {
|
||||||
|
"thank you", "thanks for watching", "thank you for watching",
|
||||||
|
"subscribe", "please subscribe", "like and subscribe",
|
||||||
|
"music", "[music]", "(music)", "♪", "♫",
|
||||||
|
"you", ".", "..", "...", "thank you.",
|
||||||
|
}
|
||||||
|
segs_all = []
|
||||||
|
for ci, L in enumerate(cands):
|
||||||
|
write_progress(50 + int(40 * ci / max(1, len(cands))), "Transcribing")
|
||||||
|
seg_iter, _ = model.transcribe(
|
||||||
|
audio, language=L, word_timestamps=True, beam_size=5,
|
||||||
|
vad_filter=(not args.no_vad), vad_parameters=VAD_PARAMS,
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
no_speech_threshold=0.70,
|
||||||
|
log_prob_threshold=-1.4,
|
||||||
|
)
|
||||||
|
for s in seg_iter:
|
||||||
|
# Drop clear non-speech and low-confidence hallucinations on
|
||||||
|
# instrumental sections, but keep genuinely-sung (lower-conf) lines.
|
||||||
|
if getattr(s, "no_speech_prob", 0.0) > 0.70:
|
||||||
|
continue
|
||||||
|
if getattr(s, "avg_logprob", 0.0) < -1.4:
|
||||||
|
continue
|
||||||
|
text = (s.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
# Drop the well-known Whisper boilerplate hallucinations.
|
||||||
|
if text.lower().strip(".,!? ") in HALLUCINATIONS:
|
||||||
|
continue
|
||||||
|
# Drop "compression ratio" gibberish — pathological repeats.
|
||||||
|
if getattr(s, "compression_ratio", 1.0) > 2.4:
|
||||||
|
continue
|
||||||
|
segs_all.append({
|
||||||
|
"start": float(s.start), "end": float(s.end), "lang": L,
|
||||||
|
"score": float(getattr(s, "avg_logprob", -5.0)),
|
||||||
|
"text": text, "words": list(s.words or []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Resolve overlaps using OUTPUT SCRIPT as the language signal ─────
|
||||||
|
# avg_logprob alone is unreliable (the Thai pass can "win" English
|
||||||
|
# regions yet output Latin). The script actually produced is the
|
||||||
|
# truth: a non-Latin-language pass that emitted Latin text is a
|
||||||
|
# mis-forced English region — drop it. Native non-Latin script wins
|
||||||
|
# overlaps so Thai regions never get the romanised English version.
|
||||||
|
def nonlatin_frac(t):
|
||||||
|
letters = [c for c in t if c.isalpha()]
|
||||||
|
if not letters:
|
||||||
|
return 0.0
|
||||||
|
return sum(1 for c in letters if not ("a" <= c.lower() <= "z")) / len(letters)
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
for s in segs_all:
|
||||||
|
nl = nonlatin_frac(s["text"])
|
||||||
|
s["native"] = 1 if nl >= 0.5 else 0
|
||||||
|
if s["lang"] in NONLATIN_LANGS and nl < 0.3:
|
||||||
|
continue # Thai (etc.) pass that produced Latin = mis-forced English
|
||||||
|
kept.append(s)
|
||||||
|
|
||||||
|
kept.sort(key=lambda x: (x["native"], x["score"]), reverse=True)
|
||||||
|
accepted = []
|
||||||
|
for s in kept:
|
||||||
|
if any(overlap_ratio(s, a) > 0.4 for a in accepted):
|
||||||
|
continue
|
||||||
|
accepted.append(s)
|
||||||
|
accepted.sort(key=lambda x: x["start"])
|
||||||
|
|
||||||
|
dur = defaultdict(float)
|
||||||
|
for s in accepted:
|
||||||
|
dur[s["lang"]] += s["end"] - s["start"]
|
||||||
|
dominant = max(dur, key=dur.get) if dur else (cands[0] if cands else "en")
|
||||||
|
trusted = set(dur.keys()) or set(cands)
|
||||||
|
|
||||||
|
# ── Build karaoke lines ────────────────────────────────────────────
|
||||||
|
lines = []
|
||||||
|
for s in accepted:
|
||||||
|
compact = s["text"].replace(" ", "")
|
||||||
|
if len(compact) >= 8 and len(set(compact)) <= 1: # degenerate "ㄷㄷㄷ"
|
||||||
|
continue
|
||||||
|
words = []
|
||||||
|
for w in s["words"]:
|
||||||
|
if w.start is None or w.end is None:
|
||||||
|
continue
|
||||||
|
tok = (w.word or "").strip()
|
||||||
|
if not tok:
|
||||||
|
continue
|
||||||
|
words.append({"start": round(float(w.start), 3),
|
||||||
|
"end": round(float(w.end), 3), "text": tok})
|
||||||
|
if words:
|
||||||
|
lines += split_into_lines(words, s["lang"])
|
||||||
|
else:
|
||||||
|
lines.append({"start": round(s["start"], 3), "end": round(s["end"], 3),
|
||||||
|
"text": s["text"], "lang": s["lang"], "words": []})
|
||||||
|
return lines, dominant, trusted
|
||||||
|
finally:
|
||||||
|
del model
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
all_lines, dominant, trusted, last_err = [], "en", set(), None
|
||||||
|
for dev, ct in [("cuda", "float16"), ("cuda", "int8"), ("cpu", "int8")]:
|
||||||
|
try:
|
||||||
|
all_lines, dominant, trusted = transcribe_all(dev, ct)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if is_oom(e):
|
||||||
|
log(f"[fw] {dev}/{ct} ran out of memory; retrying lighter")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err if last_err else RuntimeError("transcription failed")
|
||||||
|
|
||||||
|
all_lines.sort(key=lambda ln: ln["start"])
|
||||||
|
all_lines = merge_fragments(all_lines)
|
||||||
|
|
||||||
|
# If the uploader provided lyrics in the song description, ALIGN those exact
|
||||||
|
# lines to the audio (using the whisper timing) instead of using the noisier
|
||||||
|
# whisper text. The transcription pass still ran — it's what provides the
|
||||||
|
# anchoring timestamps the user lines snap to.
|
||||||
|
source = "faster-whisper"
|
||||||
|
if args.user_lyrics and os.path.isfile(args.user_lyrics):
|
||||||
|
write_progress(92, "Syncing description lyrics")
|
||||||
|
try:
|
||||||
|
user_lines = [l.strip() for l in open(args.user_lyrics, encoding="utf-8")
|
||||||
|
.read().splitlines() if l.strip()]
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[user-lyrics] read failed ({e})")
|
||||||
|
user_lines = []
|
||||||
|
if user_lines:
|
||||||
|
# Hybrid alignment: whisper-anchored where whisper heard the song,
|
||||||
|
# description-filled where whisper missed. Gap-filled lines snap
|
||||||
|
# to vocal-active moments detected by Silero VAD so they sit on
|
||||||
|
# actual singing instead of drifting across instrumental beats.
|
||||||
|
audio_duration = len(audio) / SR
|
||||||
|
vocal_regions = []
|
||||||
|
if not args.no_vocal_gapfill:
|
||||||
|
try:
|
||||||
|
vad_opts = VadOptions(threshold=0.20,
|
||||||
|
min_speech_duration_ms=400,
|
||||||
|
min_silence_duration_ms=500,
|
||||||
|
speech_pad_ms=120)
|
||||||
|
raw = get_speech_timestamps(audio, vad_opts)
|
||||||
|
vocal_regions = [(r["start"] / SR, r["end"] / SR) for r in raw]
|
||||||
|
log(f"[vad] {len(vocal_regions)} vocal regions detected")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[vad] failed ({e}); falling back to even spread in gaps")
|
||||||
|
else:
|
||||||
|
log("[vad] vocal-region gap-fill disabled by admin toggle")
|
||||||
|
corrected = correct_whisper_with_description(
|
||||||
|
all_lines, user_lines, audio_duration, vocal_regions
|
||||||
|
)
|
||||||
|
if corrected:
|
||||||
|
all_lines = corrected
|
||||||
|
source = "description-aligned"
|
||||||
|
log(f"[user-lyrics] aligned: description={len(user_lines)} "
|
||||||
|
f"output={len(all_lines)} duration={audio_duration:.1f}s")
|
||||||
|
|
||||||
|
write_progress(95, "Finishing")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"language": dominant,
|
||||||
|
"source": source,
|
||||||
|
"model": args.model,
|
||||||
|
"demucs": used_demucs,
|
||||||
|
"multilingual": True,
|
||||||
|
"lines": all_lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
out_dir = os.path.dirname(args.out)
|
||||||
|
if out_dir:
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
with open(args.out, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, ensure_ascii=False)
|
||||||
|
log(f"[done] wrote {len(payload['lines'])} lines ({sorted(trusted)}) -> {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
281
public/fp.js
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
/* TAKEONE device fingerprint — strongest guest identifier a browser will let us compute.
|
||||||
|
*
|
||||||
|
* Combines ~15 signals (canvas, WebGL, audio, fonts, screen, locale, hardware) into a
|
||||||
|
* stable 64-char hash. Cached in localStorage AND mirrored to a `_fp` cookie so the
|
||||||
|
* server sees it on every request. Falls back gracefully if any single signal fails
|
||||||
|
* (private browsing, locked-down browsers, no GPU, etc).
|
||||||
|
*
|
||||||
|
* NOT a MAC address — browsers cannot expose MAC. This is the closest practical equivalent.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var STORAGE_KEY = '_takeone_fp';
|
||||||
|
var COOKIE_KEY = '_fp';
|
||||||
|
var COOKIE_MAX = 60 * 60 * 24 * 365 * 5; // 5 years
|
||||||
|
|
||||||
|
// ── Set cookie helper ────────────────────────────────────────────
|
||||||
|
function setCookie(name, val) {
|
||||||
|
try {
|
||||||
|
document.cookie =
|
||||||
|
name + '=' + encodeURIComponent(val) +
|
||||||
|
'; Max-Age=' + COOKIE_MAX +
|
||||||
|
'; Path=/; SameSite=Lax' +
|
||||||
|
(location.protocol === 'https:' ? '; Secure' : '');
|
||||||
|
} catch (e) { /* sandboxed iframe etc. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SHA-256 (Web Crypto where available, JS fallback otherwise) ──
|
||||||
|
function sha256(str) {
|
||||||
|
if (window.crypto && window.crypto.subtle && window.TextEncoder) {
|
||||||
|
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
|
||||||
|
.then(function (buf) {
|
||||||
|
return Array.from(new Uint8Array(buf))
|
||||||
|
.map(function (b) { return b.toString(16).padStart(2, '0'); })
|
||||||
|
.join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Minimal JS fallback (only used in ancient browsers)
|
||||||
|
return Promise.resolve(jsSha256(str));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny pure-JS SHA-256 (used only if subtleCrypto missing). Adapted from public-domain refs.
|
||||||
|
function jsSha256(ascii) {
|
||||||
|
function rightRotate(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
|
||||||
|
var mathPow = Math.pow, maxWord = mathPow(2, 32), result = '';
|
||||||
|
var words = [], asciiBitLength = ascii.length * 8;
|
||||||
|
var hash = jsSha256.h = jsSha256.h || [], k = jsSha256.k = jsSha256.k || [], primeCounter = k.length;
|
||||||
|
var isComposite = {};
|
||||||
|
for (var candidate = 2; primeCounter < 64; candidate++) {
|
||||||
|
if (!isComposite[candidate]) {
|
||||||
|
for (var i = 0; i < 313; i += candidate) isComposite[i] = candidate;
|
||||||
|
hash[primeCounter] = (mathPow(candidate, .5) * maxWord) | 0;
|
||||||
|
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ascii += '\x80';
|
||||||
|
while (ascii.length % 64 - 56) ascii += '\x00';
|
||||||
|
for (i = 0; i < ascii.length; i++) {
|
||||||
|
var j = ascii.charCodeAt(i);
|
||||||
|
if (j >> 8) return '';
|
||||||
|
words[i >> 2] |= j << ((3 - i) % 4) * 8;
|
||||||
|
}
|
||||||
|
words[words.length] = ((asciiBitLength / maxWord) | 0);
|
||||||
|
words[words.length] = (asciiBitLength);
|
||||||
|
for (j = 0; j < words.length;) {
|
||||||
|
var w = words.slice(j, j += 16), oldHash = hash;
|
||||||
|
hash = hash.slice(0, 8);
|
||||||
|
for (i = 0; i < 64; i++) {
|
||||||
|
var w15 = w[i - 15], w2 = w[i - 2];
|
||||||
|
var a = hash[0], e = hash[4];
|
||||||
|
var temp1 = hash[7]
|
||||||
|
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25))
|
||||||
|
+ ((e & hash[5]) ^ ((~e) & hash[6]))
|
||||||
|
+ k[i]
|
||||||
|
+ (w[i] = (i < 16) ? w[i] : (
|
||||||
|
w[i - 16]
|
||||||
|
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3))
|
||||||
|
+ w[i - 7]
|
||||||
|
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))
|
||||||
|
) | 0);
|
||||||
|
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22))
|
||||||
|
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
|
||||||
|
hash = [(temp1 + temp2) | 0].concat(hash);
|
||||||
|
hash[4] = (hash[4] + temp1) | 0;
|
||||||
|
}
|
||||||
|
for (i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0;
|
||||||
|
}
|
||||||
|
for (i = 0; i < 8; i++) {
|
||||||
|
for (j = 3; j + 1; j--) {
|
||||||
|
var b = (hash[i] >> (j * 8)) & 255;
|
||||||
|
result += ((b < 16) ? 0 : '') + b.toString(16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signal probes ────────────────────────────────────────────────
|
||||||
|
function canvasFingerprint() {
|
||||||
|
try {
|
||||||
|
var c = document.createElement('canvas');
|
||||||
|
c.width = 280; c.height = 60;
|
||||||
|
var ctx = c.getContext('2d');
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = "14px 'Arial'";
|
||||||
|
ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20);
|
||||||
|
ctx.fillStyle = '#069'; ctx.fillText('TAKEONE,fp 🎬', 2, 15);
|
||||||
|
ctx.fillStyle = 'rgba(102,204,0,0.7)';
|
||||||
|
ctx.fillText('TAKEONE,fp 🎬', 4, 17);
|
||||||
|
// Curved shape exposes GPU sub-pixel rounding differences
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(50, 30, 20, 0, Math.PI * 2, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = 'rgb(255,0,255)';
|
||||||
|
ctx.fill();
|
||||||
|
return c.toDataURL();
|
||||||
|
} catch (e) { return 'canvas:err'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function webglFingerprint() {
|
||||||
|
try {
|
||||||
|
var c = document.createElement('canvas');
|
||||||
|
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
|
||||||
|
if (!gl) return 'webgl:none';
|
||||||
|
var info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
var vendor = info ? gl.getParameter(info.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
||||||
|
var renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||||
|
var max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
||||||
|
var ext = (gl.getSupportedExtensions() || []).sort().join(',');
|
||||||
|
return vendor + '|' + renderer + '|' + max + '|' + ext;
|
||||||
|
} catch (e) { return 'webgl:err'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioFingerprint() {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
try {
|
||||||
|
var Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||||
|
if (!Ctx) return resolve('audio:none');
|
||||||
|
var ctx = new Ctx(1, 44100, 44100);
|
||||||
|
var osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle'; osc.frequency.value = 10000;
|
||||||
|
var compressor = ctx.createDynamicsCompressor();
|
||||||
|
['threshold','knee','ratio','attack','release'].forEach(function (p) {
|
||||||
|
if (compressor[p] && compressor[p].setValueAtTime) {
|
||||||
|
compressor[p].setValueAtTime(({
|
||||||
|
threshold:-50, knee:40, ratio:12, attack:0, release:.25
|
||||||
|
})[p], ctx.currentTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
osc.connect(compressor); compressor.connect(ctx.destination);
|
||||||
|
osc.start(0); ctx.startRendering();
|
||||||
|
var done = false;
|
||||||
|
ctx.oncomplete = function (e) {
|
||||||
|
if (done) return; done = true;
|
||||||
|
var sum = 0, d = e.renderedBuffer.getChannelData(0);
|
||||||
|
for (var i = 4500; i < 5000; i++) sum += Math.abs(d[i]);
|
||||||
|
resolve(sum.toString());
|
||||||
|
};
|
||||||
|
setTimeout(function () { if (!done) { done = true; resolve('audio:timeout'); } }, 1500);
|
||||||
|
} catch (e) { resolve('audio:err'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontsFingerprint() {
|
||||||
|
// Quick probe: list which of N common fonts the OS actually has installed,
|
||||||
|
// detected by measuring text width against fallback baselines.
|
||||||
|
try {
|
||||||
|
var baseFonts = ['monospace','sans-serif','serif'];
|
||||||
|
var testString = 'mmmmmmmmmmlli';
|
||||||
|
var testSize = '72px';
|
||||||
|
var fonts = [
|
||||||
|
'Andale Mono','Arial','Arial Black','Arial Hebrew','Arial MT','Arial Narrow','Arial Rounded MT Bold','Arial Unicode MS',
|
||||||
|
'Bitstream Vera Sans Mono','Book Antiqua','Bookman Old Style','Calibri','Cambria','Cambria Math','Century','Century Gothic','Century Schoolbook',
|
||||||
|
'Comic Sans','Comic Sans MS','Consolas','Courier','Courier New','Geneva','Georgia','Helvetica','Helvetica Neue','Impact',
|
||||||
|
'Lucida Bright','Lucida Calligraphy','Lucida Console','Lucida Fax','LUCIDA GRANDE','Lucida Handwriting','Lucida Sans','Lucida Sans Typewriter','Lucida Sans Unicode',
|
||||||
|
'Microsoft Sans Serif','Monaco','Monotype Corsiva','MS Gothic','MS Outlook','MS PGothic','MS Reference Sans Serif','MS Sans Serif','MS Serif','MYRIAD','MYRIAD PRO',
|
||||||
|
'Palatino','Palatino Linotype','Segoe Print','Segoe Script','Segoe UI','Segoe UI Light','Segoe UI Semibold','Segoe UI Symbol','Tahoma','Times','Times New Roman','Times New Roman PS','Trebuchet MS','Verdana','Wingdings','Wingdings 2','Wingdings 3'
|
||||||
|
];
|
||||||
|
var body = document.body || document.documentElement;
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.style.position = 'absolute'; span.style.left = '-9999px';
|
||||||
|
span.style.fontSize = testSize; span.textContent = testString;
|
||||||
|
body.appendChild(span);
|
||||||
|
var defaults = {};
|
||||||
|
baseFonts.forEach(function (b) {
|
||||||
|
span.style.fontFamily = b;
|
||||||
|
defaults[b] = { w: span.offsetWidth, h: span.offsetHeight };
|
||||||
|
});
|
||||||
|
var detected = [];
|
||||||
|
fonts.forEach(function (f) {
|
||||||
|
var diff = false;
|
||||||
|
for (var i = 0; i < baseFonts.length; i++) {
|
||||||
|
span.style.fontFamily = "'" + f + "'," + baseFonts[i];
|
||||||
|
if (span.offsetWidth !== defaults[baseFonts[i]].w ||
|
||||||
|
span.offsetHeight !== defaults[baseFonts[i]].h) { diff = true; break; }
|
||||||
|
}
|
||||||
|
if (diff) detected.push(f);
|
||||||
|
});
|
||||||
|
body.removeChild(span);
|
||||||
|
return detected.join(',');
|
||||||
|
} catch (e) { return 'fonts:err'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSignals() {
|
||||||
|
var nav = window.navigator || {};
|
||||||
|
var scr = window.screen || {};
|
||||||
|
return Promise.all([audioFingerprint()]).then(function (results) {
|
||||||
|
return {
|
||||||
|
canvas : canvasFingerprint(),
|
||||||
|
webgl : webglFingerprint(),
|
||||||
|
audio : results[0],
|
||||||
|
fonts : fontsFingerprint(),
|
||||||
|
screen : (scr.width || 0) + 'x' + (scr.height || 0) + 'x' + (scr.colorDepth || 0),
|
||||||
|
dpr : window.devicePixelRatio || 1,
|
||||||
|
platform : nav.platform || '',
|
||||||
|
cpu : nav.hardwareConcurrency || 0,
|
||||||
|
mem : nav.deviceMemory || 0,
|
||||||
|
tz : (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
|
||||||
|
langs : (nav.languages || []).join(','),
|
||||||
|
touch : (nav.maxTouchPoints || 0) + (('ontouchstart' in window) ? 'T' : ''),
|
||||||
|
pdfviewer : nav.pdfViewerEnabled ? '1' : '0',
|
||||||
|
userAgent : nav.userAgent || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build / cache the hash ───────────────────────────────────────
|
||||||
|
function ensureFingerprint() {
|
||||||
|
// 1) localStorage first (fastest path)
|
||||||
|
var cached = null;
|
||||||
|
try { cached = localStorage.getItem(STORAGE_KEY); } catch (e) {}
|
||||||
|
// 2) cookie next (survives some localStorage wipes)
|
||||||
|
if (!cached) cached = readCookie(COOKIE_KEY);
|
||||||
|
|
||||||
|
if (cached && /^[a-f0-9]{64}$/.test(cached)) {
|
||||||
|
setCookie(COOKIE_KEY, cached); // refresh expiry on each visit
|
||||||
|
window._takeoneFp = cached;
|
||||||
|
return Promise.resolve(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectSignals().then(function (sig) {
|
||||||
|
var serialised = JSON.stringify(sig);
|
||||||
|
return sha256(serialised).then(function (hash) {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, hash); } catch (e) {}
|
||||||
|
setCookie(COOKIE_KEY, hash);
|
||||||
|
window._takeoneFp = hash;
|
||||||
|
|
||||||
|
// Backfill the view row that the server just inserted from the cookie-less first visit
|
||||||
|
try {
|
||||||
|
var pathMatch = location.pathname.match(/^\/videos\/([^\/?#]+)/);
|
||||||
|
if (pathMatch) {
|
||||||
|
var token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
|
fetch('/videos/' + pathMatch[1] + '/identify', {
|
||||||
|
method : 'POST',
|
||||||
|
headers : {
|
||||||
|
'Content-Type' : 'application/json',
|
||||||
|
'X-CSRF-TOKEN' : token,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ hash: hash })
|
||||||
|
}).catch(function () { /* best-effort */ });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off ASAP — but never block paint
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', ensureFingerprint, { once: true });
|
||||||
|
} else {
|
||||||
|
ensureFingerprint();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1
public/vendor/flag-icons/css/flag-icons.min.css
vendored
Normal file
150
public/vendor/flag-icons/flags/4x3/ad.svg
vendored
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 640 480">
|
||||||
|
<path fill="#d0103a" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fedf00" d="M0 0h435.2v480H0z"/>
|
||||||
|
<path fill="#0018a8" d="M0 0h204.8v480H0z"/>
|
||||||
|
<path fill="#c7b37f" d="M300.4 136.6c7.7 0 10.9 6.6 18.6 6.6 4.7 0 7.5-1.5 11.7-3.9 2.9-1.6 4.7-2.5 8-2.5 3.4 0 5.5 1 7.3 4 1 1.6 1.8 4.9 1.3 6.7a40 40 0 0 1-2.7 8.3c-.7 1.6-1.3 2.5-1.3 4.2 0 4.1 5.6 5.5 9.4 5.6.8 0 7.7 0 12-4.2-2.3-.1-4.9-2-4.9-4.3 0-2.6 1.8-4.3 4.3-5.1.5-.1 1.3.3 1.7 0 .7-.3.4-1 1-1.4 1.2-1 2-1.6 3.6-1.6 1 0 1.6.1 2.5.7.4.4.6.8 1 .8 1.2 0 1.8-.8 3-.8a5 5 0 0 1 2.3.6c.6.3.6 1.5 1.4 1.5.4 0 2.4-.9 3.5-.9 2.2 0 3.4.8 4.8 2.5.4.5.6 1.4 1 1.4a6.2 6.2 0 0 1 4.8 3c.3.4.7 1.4 1.1 1.5.6.3 1 .2 1.7.7a6 6 0 0 1 2.8 4.8c0 .7-.3 1.6-.5 2.2-1.8 6.5-6.3 8.6-10.8 14.3-2 2.4-3.5 4.3-3.5 7.4 0 .7 1 2.1 1.3 2.7-.2-1.4.5-3.2 2-3.3a4 4 0 0 1 4 3.6 4.5 4.5 0 0 1-.3 1.8 9.6 9.6 0 0 1 4-1.4h1.9c3.3 0 7 1.9 9.3 3.8a21 21 0 0 1 7.3 16.8c-.8 5.2-.3 14.8-13.8 18.6 2.5 1 4.2 3 4.2 5.2a4.5 4.5 0 0 1-4.4 4.7 4.4 4.4 0 0 1-3.5-1.4c-2.8 2.8-3.3 5.7-3.3 9.7 0 2.4.4 3.8 1.4 6 1 2.2 1.8 3.5 3.7 5.1 1-1.5 2.1-2.6 4-2.6 1.7 0 3.2.6 3.9 2.2.2.5 0 .9.3 1.4.3.6.8.7 1.1 1.3.5 1 0 1.8.5 2.7.3.7.9.8 1.2 1.4.4 1 .5 1.6.5 2.7 0 3-2.7 5.2-5.7 5.2-1 0-1.4-.4-2.3-.3 1.7 1.7 3 2.5 4.3 4.5a17.7 17.7 0 0 1 3 10.3 22 22 0 0 1-2.8 11.2 20 20 0 0 1-7 8.5 35 35 0 0 1-16 6.4 74.4 74.4 0 0 1-11 1.4l-14.1.8c-7.2.4-12.2 1.5-17.3 6.6 2.4 1.7 4 3.5 4 6.4 0 3-1.8 5.3-4.7 6.2-.7.2-1.2 0-1.9.4s-.7 1.3-1.4 1.7a6.2 6.2 0 0 1-3.8 1 8 8 0 0 1-6.4-2.5c-2.2 1.8-3 3.4-5.5 4.9-.8.4-1.2 1-2.1 1-1.5 0-2.2-1-3.4-1.8a23 23 0 0 1-4.4-4c-2.3 1.3-3.6 2.4-6.3 2.4a7 7 0 0 1-4-1c-.6-.5-.8-1.2-1.5-1.6-.7-.5-1.3-.3-2.1-.7-3-1.3-5-3.5-5-6.8 0-2.9 1.8-4.7 4.4-6-5-5-10-5.8-17-6.2l-14-.8c-4.4-.3-6.8-.7-11-1.4-3.3-.5-5.2-.7-8.2-2.1-10.2-4.8-16.8-11.3-18-22.5-.2-1-.2-1.5-.2-2.5 0-5.8 2.3-9.4 6.4-13.5-1-.3-1.7 0-2.8-.3-2.5-1-4.4-2.7-4.4-5.5 0-1 0-1.7.5-2.6.4-.6 1-.7 1.2-1.4.2-1 0-1.6.4-2.5.3-.5.8-.6 1-1.2 1-1.9 2-3.4 4.1-3.4 1.8 0 3 1 3.8 2.5 1.8-.8 2.2-2.1 3.2-3.7a15.5 15.5 0 0 0 1.4-13.3c-.4-1.5-.6-2.5-1.8-3.7-1 1-2 1.4-3.4 1.4-2.9 0-5-2.5-5-5.3a4.8 4.8 0 0 1 3-4.6c-1.6-1.4-3-1.5-4.7-2.6-2.6-1.6-3.5-3.4-5.2-6-1.2-1.6-1.5-2.8-2-4.7a19 19 0 0 1-1-7.8c.6-5 1.5-8 4.6-11.9 1.8-2.3 3-3.7 5.8-4.9 2.3-1 3.7-1.7 6.2-1.7l2 .1a6.9 6.9 0 0 1 2.8.8c.4.2 1.1.9 1.1.4s-.3-.8-.3-1.3c0-2 1.5-4 3.6-4 1.5 0 2.1 1.4 2.9 2.7.4-.8.7-1.4.7-2.3 0-3.4-1.9-5.2-4-7.9-4.7-5.8-10.5-8.5-10.5-16 0-2.2 1-3.7 3-4.9.5-.3 1.3 0 1.8-.3s.4-1 .7-1.4c.5-.7 1-1 1.6-1.6 1-1 2-.6 3.1-1.5.6-.4.8-1 1.2-1.4 1.3-1.6 2.5-2.4 4.6-2.4 1 0 1.6 0 2.5.4l1 .5c.3-.2.8-.8 1.5-1.1a4 4 0 0 1 2.2-.6c1.1 0 1.8.6 3 .6.3 0 .4-.4.8-.6 1-.7 1.5-1 2.7-1 1.2 0 1.8.3 2.8 1 1 .5 1 1.3 2 1.8.5.3 1 .2 1.5.4 2.6.9 4.5 2.6 4.5 5.3 0 1.5-.3 2.5-1.4 3.5-.9.7-1.7.6-2.8 1a16 16 0 0 0 11.3 3.5c4.2 0 9.3-1.7 9.3-5.9 0-2-1-3-1.8-4.8a18.8 18.8 0 0 1-2.1-8.5c0-2.8.3-4.5 1.9-6.7 1.6-2.3 3.6-2.9 6.5-2.9"/>
|
||||||
|
<g fill="none" stroke="#703d29">
|
||||||
|
<path stroke-linejoin="round" stroke-width=".7" d="M272.4 159a3.6 3.6 0 0 0 2.4 2.4c.8.3 2.7.2 3.8-1.4 1-1.2 1-2.8.6-4a4.7 4.7 0 0 0-1.7-2.2z"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M401 236.1c-1.2-2.9-4.3-1.6-4.4 0-.5 3.7 2.7 4.8 5 4.2a4 4 0 0 0 2.5-2c.6-1 .8-2.4.4-3.7a4.9 4.9 0 0 0-.8-1.6 5 5 0 0 0-1.3-1.2c-.9-.6-1.9-.6-3.4-.6-5.5 0-10.4 6.5-12 13.4-.6 2.2-1.3 7.3-.3 12a22.4 22.4 0 0 0 5.9 11.3 25.7 25.7 0 0 0 9.9 5.8 7.9 7.9 0 0 0 4 .1c3.2-.7 4.7-3.8 3-7-1.3-2.5-5.3-4-7.2-.6-.1.3-.4.9-.4 1.5 0 .9.4 2 1 2.4 1.5.9 3.8.6 3.7-2"/>
|
||||||
|
<path stroke-width=".8" d="M383.8 274a11.3 11.3 0 0 1 6.6-3.7c3-.4 5.6.5 8.2 2a18.5 18.5 0 0 1 10.8 17c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14-7.1 3-18 3.6-25.7 4-10.4.3-20 .7-25.5 7.6"/>
|
||||||
|
<g stroke-width=".7">
|
||||||
|
<path d="M386.4 285.7c-.3-1 0-2.1.8-3.3 1.2-1.6 3.7-2.1 6-1a7.4 7.4 0 0 1 2.5 2.2l1.1 1.6c.7 1.1 1 2 1 2.5 2.5 7-1.4 14.5-6.5 17.6-4 2.4-8.7 3.4-14.4 4-2.5.4-4 .3-6.5.5h-9.6a70.1 70.1 0 0 0-7.2 0c-2.9.3-5 .4-7.6.8-1.6.2-3.4.5-5.4 1-.6 0-1.2.2-1.8.4l-1.2.3c-3.6 1.1-7 2.4-9.8 4.2-.8.5-1.8 1-2.5 1.7l-1.3 1.2c-2 2-3.9 4-4.4 6.7v1.6c0 1.8 1.4 4.3 5.4 5m5.5-170c.8 1.4 1.3 2.3.8 3.9-.6 1.7-1.8 2.8-3.6 2.8-4 0-6.3-4.8-4.5-7.8 3.2-5.3 9.3-2.3 15 .3-.3-1.3-.8-1.8-.7-3.5.1-4.2 3.2-6 4.5-10 .7-2.3 1-4.3-.7-6-1.5-1.3-3.2-1.3-5.1-.6-3.8 1.5-8.5 5.9-16.6 6-8.2-.1-12.8-4.5-16.7-6-2-.7-3.6-.7-5.1.7-1.7 1.6-1.4 3.6-.7 6 1.3 3.8 4.4 5.7 4.5 10 0 1.6-.4 2-.7 3.4 5.7-2.6 12-5.9 15-.3 1.7 3.2-.5 7.7-4.5 7.7-1.8 0-3-1-3.6-2.7-.4-1.5 0-2.8.8-4"/>
|
||||||
|
<path stroke-linecap="round" d="M314.6 159.9a5.3 5.3 0 0 1 2.4 5c-.2 2.5-.8 3.1-2.8 4.5m2.4-3.8c-.1 1.5-.7 2.5-2.3 3.1"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#c7b37f" stroke="none" d="m276.7 153.3.7.5.8.8.5 1 .2.8v1.9l-.2.8-.5.6-.6.6-.9.5-1 .2-1 .2-1-.5-.9-.6-.5-.8-.4-1v-.4z"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M275.2 157.2c-.3-1.7-2.2-2-3-1-1.1 1.5-.3 4 2 4.7a4 4 0 0 0 3.9-1.4c1-1.3.9-2.8.5-4a4.5 4.5 0 0 0-1.7-2.2c-2.7-2-7.1-1.6-8.6 2-1.8 4.4 2.2 7.8 6 10.3 4.6 3.2 10 3.8 14 3.8 9.2-.1 16.2-4.5 20.7-7 1-.6 2.1-.5 2.7.2a2 2 0 0 1-.3 2.7"/>
|
||||||
|
<path stroke-width=".7" d="m248 281.2-2 .7-2 1.6-1 1.3-1.1 2-.5 1.5-.4 1.8-.2 1.4m19-10.1-.1 1.8-.3 1.2-1 2.2-1.3 1.8-1.5 1.2-1.1.5-1.6.4"/>
|
||||||
|
<path stroke-width=".8" d="M319.7 329.1c-.3 1.7-1.9 3.6-5.3 4.2l-.6.2"/>
|
||||||
|
<path stroke-width=".9" d="M404.2 276.2a18.3 18.3 0 0 1 5.6 13.5c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14a85 85 0 0 1-25.6 4c-10.3.3-19.8.7-25.4 7.3"/>
|
||||||
|
<path stroke-width=".6" d="M387.5 282.9c.8-1 3.5-2.4 5.8-1.1a6.2 6.2 0 0 1 2.3 2"/>
|
||||||
|
<path stroke-width=".9" d="m401.6 273.8 1.4.5a7 7 0 0 0 4 0c2.8-.8 4.6-3.4 3.2-6.9a6 6 0 0 0-1.8-2.1"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M240.3 199.8c-2 1.1-3.3 1.4-4.8 3.1a28.1 28.1 0 0 0-2.6 6.8m46-51.7c0 1.8-1.2 2.8-3 3.2"/>
|
||||||
|
<path stroke-width=".6" d="M397.1 192a19 19 0 0 1 18.6 19.8c0 16-9.9 18.5-13.8 19.6"/>
|
||||||
|
<path stroke-width=".7" d="M398.4 192c8.1-.3 16.5 5.7 16.9 20.7.3 11.7-8 17-12 18"/>
|
||||||
|
<path stroke-width=".6" d="m393.8 248.4.1-1.6.6-2.5.7-2 .9-1.6 1-1.3m7.8-3.4v1.5l-.5 1-.7 1.1-.8.6-1.2.5h-1.1l-.8-.1m-14.3-52.8.3-1.7.8-1.6 1-1.5 1.6-2.2 1.4-1.4 2-2.2 2-1.9 1.1-1.3 1.5-1.9 1.4-2 .8-1.7.5-2.2.1-2.7-.2-.8m-12.3 128.2 1.6-.4 1.2-.6.7-.7.5-.8.3-1.2v-.9m-158.2-12.1h2.7l1.6-.6m5-36.5-.2 1.4-.4.6-.4.6-.7.5-.7.3-1 .1h-.6m9.9-15.5-.3 2.1-.5 1-.8 1.2-1.2.9-1.2.6-2.3.5m15.3-39.7-.5 1.3-.5 1-.8 1-1 1-1.2.5-1.1.3-.6-.1m.3-6.2v1"/>
|
||||||
|
<g stroke-width=".6">
|
||||||
|
<path stroke-linecap="round" d="M254.3 224a6.9 6.9 0 0 1-2.1 1.4m150.5 44.8.5.2c1.4.8 4.2-.2 3.4-2.4"/>
|
||||||
|
<path d="M397.8 239.6c1 1.3 2.9 1.7 4.4 1.3a4 4 0 0 0 2.5-2c.6-1 .8-2.4.4-3.7a4.9 4.9 0 0 0-.9-1.6 6.8 6.8 0 0 0-1.3-1.5l-.4-.2m6.4 34a4 4 0 0 0 .1-.7 4 4 0 0 0-1.3-3l-.8-.8m.4.5c0-1.8-1.5-3.2-3.4-3.5m-4.2 2.8-1.3-1a15.7 15.7 0 0 1-4.3-10.7c0-4.2 1.6-8.4 3.6-10M341.2 324l1.8-1.6 1.2-1 2.3-1.4 2.2-1 1.6-.5 3-.6 3.6-.6m-29.5 19.4a17 17 0 0 1-7.6 6.1 17.7 17.7 0 0 1-7.6-6.1"/>
|
||||||
|
<path stroke-linecap="round" d="M314.4 332.6a10 10 0 0 1-2.2 4.2"/>
|
||||||
|
<path d="m314.7 330.5-.4 2.2M312 337l-1 1-1.7.9-2 .6m-5.6-177.8c.3-.8.5-1.4.5-2.6-.1-4.2-3.2-6.1-4.5-10-.7-2.3-1-4.3.7-6 1.4-1.4 3.2-1.4 5-.6 4 1.5 8.6 5.8 16.7 6-8.1-.2-12.8-4.5-16.6-6-2-.8-3.8-1-5.3.5-1.7 1.6-1.2 3.8-.5 6.1 1.3 3.9 4.2 5.8 4.3 10 0 1.2-.3 1.8-.5 2.6M320 148c8-.4 14.9-5.8 17.1-6.3 2-.4 3-.2 4.5 1.1-1.4-1.3-3-1.2-5-.5-3.8 1.5-8.4 5.8-16.6 6m79.6 112.9a15.5 15.5 0 0 1-6.2-12.4c0-4.1 1.7-8.4 3.6-10m-70 97.6c-1.3 2-4.3 5-7.6 6.2a17.7 17.7 0 0 1-7.6-6.2"/>
|
||||||
|
<path stroke-linecap="round" d="m306.7 163.7 2.3-1.3c1-.6 2.3-.5 2.9.2.6.7.7 2-.2 2.8"/>
|
||||||
|
<path d="M294.7 169.3c5.5-1.2 10-3.6 13.4-5.5M340.3 328c.5.3.8 1 .8 1 .1.2.3.5.3.8.3 1.5-.7 2.4-2 2.6-1.7.2-3-.8-3.5-2M294.4 169c5.5-1.1 10-3.6 13.4-5.5m97.6 106.9c-1 .4-1.6.3-3-.2l-1.8-1a20.7 20.7 0 0 1-8.4-9 18.8 18.8 0 0 1-1.7-4.6 12 12 0 0 1-.5-3.3 25.6 25.6 0 0 1 4.7-15.3c1.1-1.6 2.1-2.5 4.2-2.6m-143.7-39.3a7.1 7.1 0 0 1 2.7 5.7c0 3.1-2.6 8.2-9 10a8.3 8.3 0 0 1-6.3-.8"/>
|
||||||
|
<path d="M256.3 205.6c1.1.8 1.6 1.7 1.6 3.3 0 1-.7 2.4-1.9 3.7a12.4 12.4 0 0 1-8.8 4c-2 0-4-.4-6-1.7a9 9 0 0 1-3.8-5.4"/>
|
||||||
|
<path d="M256.2 212.3c1.3 1.2 1.7 2.7 1.7 4.6 0 2.7-1.1 4.8-3.7 7-.6.6-1.2 1-2 1.5m129.5-22.1v3.5m-.3-4.4v5m.3-15.8v6.6m-.3-8v8.9m-1.9 82a18.7 18.7 0 0 1-4.2 5.6 19.6 19.6 0 0 1-5.8 4.1 24.6 24.6 0 0 1-6.6 2.2 33 33 0 0 1-6.8.9c-2.5 0-3.9 0-6.4-.2-2.6-.2-4-.6-6.7-.8-2.2-.2-3.4-.4-5.6-.3a28.3 28.3 0 0 0-11 1.8c-2.6 1-5.7 3-6.3 3.8a22 22 0 0 0-6.4-3.8 22 22 0 0 0-5.1-1.4c-2.3-.4-3.5-.4-5.8-.4-2.2 0-3.4.1-5.6.3-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.4.2a33 33 0 0 1-13.4-3 19.5 19.5 0 0 1-6.4-4.8m42.1 53.4 1.8-.2m30.3-2.4 1.8-.1 1.7-.7 1.2-.8 1.7-2 .3-.6.3-1.7v-.8m47-136.7c.7-2.6-.2-5.4-2.8-5.3m-132 46.5a8.2 8.2 0 0 1-3.5 4.7m3.6-46.7a6.5 6.5 0 0 1-3.6 4c-1.9.8-4 0-5.2-.8"/>
|
||||||
|
<path stroke-linecap="round" d="M243.8 202.4c1.5.8 3.1-.4 2.8-2.4a2.9 2.9 0 0 0-2.5-2.2"/>
|
||||||
|
<path d="M250.2 286.6c.3.3.4.7.8.8.7.2 1.2.4 1.9-.5.8-1.1.3-2.8-.5-3.9a5 5 0 0 0-5.8-1c-.8.5-1.7 1-2.6 2.2l-1.1 1.6c-.7 1.1-1 2-1.1 2.4-2 5.9.4 12 4.1 15.7"/>
|
||||||
|
<path stroke-linecap="round" d="m340.2 327.8.7.8.2.9c.3 1.5-.7 2.4-2 2.6-1.6.2-2.8-.8-3.3-2"/>
|
||||||
|
<path d="M389.4 154.8a7.4 7.4 0 0 1 6.3 7c0 4.4-1.5 6-3.8 9.2-2.5 3.4-10.7 9.6-10.7 16.7 0 4.3 1.2 7 4.3 8.4 2 1 4.3 0 5.4-1 2.6-2.4 1.5-6.5-1.2-7-3.2-.6-3.9 4.6-.7 4.3m17.9 69a3.7 3.7 0 0 0-3.6-3 3.7 3.7 0 0 0-3.7 3.7c0 1 .4 2 1 2.6"/>
|
||||||
|
<path d="M383.9 195.1a7.1 7.1 0 0 0-2.7 5.7c0 3.1 2.6 8.2 9 10 2.4.7 4.8.6 6.2-.3m-156-10.3a9.4 9.4 0 0 0-4.8 3.5 16.9 16.9 0 0 0-2.2 12.7 15.8 15.8 0 0 0 2.3 5.6 8 8 0 0 0 1 1.2l1.2 1m64 92c4.9 2.1 8.4 3.7 11.4 8.5a10 10 0 0 1 1.2 4.9c0 2.7-1 5.7-3.3 7.6a8.3 8.3 0 0 1-6.7 2c-1.9-.2-3.7-1.6-4-2.6M254 224.1c2.7 2.2 3.9 4.2 3.9 7.5a8.4 8.4 0 0 1-4 7.5"/>
|
||||||
|
<path stroke-linecap="round" d="M251.5 236.4c4 5.1 6.3 8.1 6.4 14.1.1 5.7-1.7 9.6-5 13.7"/>
|
||||||
|
<path d="M329.8 169.3a4.1 4.1 0 0 0 1.5-2.2c.5-1.5.5-2.8-.2-4 1 1.4 1 2.5.7 4-.1 1-.8 1.5-1.6 2.3m51.5 86.1v16.2l-.1 2.5a34.4 34.4 0 0 1-.3 1.7"/>
|
||||||
|
<path d="M381.4 254v19.9l-.5 2.6m.5-43v14.6m.3-13.4v11.8m0-26.8v8.8m-.3-9.9v11m.3-19v3.5m-.3-4.2v5m-1.8 65.2-.4.7a18.7 18.7 0 0 1-4.1 5.7 19.6 19.6 0 0 1-5.9 4 24.6 24.6 0 0 1-6.5 2.2c-2.7.6-4.2.8-6.9.9-2.5 0-3.9 0-6.3-.2-2.7-.2-4.1-.5-6.8-.8-2.2-.2-3.4-.3-5.6-.3-2.2 0-3.5 0-5.7.4a22 22 0 0 0-5.2 1.4c-2.7 1.1-5.7 3-6.4 3.8-.6-.8-3.7-2.7-6.3-3.8a22 22 0 0 0-5.2-1.4c-2.2-.4-3.5-.4-5.8-.4-2.2 0-3.4.1-5.6.3-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.3.2a33 33 0 0 1-13.5-3 19.5 19.5 0 0 1-5.8-4.1 22 22 0 0 1-2.5-2.8m-2-3.2a10.1 10.1 0 0 1-2.3 7.7c-.8.9-2.6 2.6-5 2.6-3.7 0-4.8-2.5-5-3.2"/>
|
||||||
|
<path d="M255.6 278.9c.7.7 1.3 1.5 1.9 2.5 1 1.8.6 4.8-.1 6.2a4.4 4.4 0 0 1-.3.4m-20.3 18c2.3 2.4 5.7 5 10.9 7.1 7.1 3 18.1 3.6 25.7 4 10 .3 19.3.7 25 7m17.3-4a12 12 0 0 1 4 5.5m-7.3 11.5a8.2 8.2 0 0 1-.7.7 8.3 8.3 0 0 1-6.6 2c-2-.2-3.8-1.6-4.3-2.6m-5.4-2.9.3.4a7.6 7.6 0 0 0 5.1 2.4m27 0a18 18 0 0 1-7.7 6.1 17.7 17.7 0 0 1-7.6-6.1l-.3-.5m15.6.4.7.7a8.3 8.3 0 0 0 6.7 2 5.5 5.5 0 0 0 4-2.5l.5-.7"/>
|
||||||
|
<path d="m339 336.6-.7 1.2-1.1 1-1.7.7h-1.6"/>
|
||||||
|
<path d="M343 325.3a7.7 7.7 0 0 1 2.4 2.9c.3.7.4 1.5.5 2.3a5.8 5.8 0 0 1-1.5 4.2 7.5 7.5 0 0 1-5.4 2.4 5.5 5.5 0 0 1-.4 0m.2-.2a6.8 6.8 0 0 1-5.2-2.2m63.7-67.9a23.8 23.8 0 0 1-4.8-6.4 18.8 18.8 0 0 1-1.7-4.5 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.6-15.3c.7-.8 1.4-1.8 2.1-2.2m-1.3-75.9c2.5.2 4.8 3 4.8 5.7 0 3.8-1.3 5.5-4.4 9.3-2.6 3.2-10.6 9-10.3 14.5 0 1 .5 2 1.1 2.8m-3.2 3.5a7 7 0 0 0 2 1.4 5 5 0 0 0 4.3-.3M369 153a6 6 0 0 1 2.2 2.6c1.8 4.5-2.2 7.9-6 10.4a21.3 21.3 0 0 1-8.3 3.3"/>
|
||||||
|
<path d="M364.6 161.6a4.2 4.2 0 0 1-3.1-1.5 3.4 3.4 0 0 1-.7-1m-15 4.9a4.6 4.6 0 0 1-1.2-1c-1-1-1.5-2.3-.8-4.4.6-1.9 3.7-7.2 3.8-10.9.2-5.6-2-9-5.3-10.2"/>
|
||||||
|
<path stroke-linecap="round" d="m347.3 146.5-.1 2-.6 2.2-1 3-1 1.9-.8 1.9-.4 1.3-.2 1 .1.9m38 126.3.6.8c.7 1 3.2 3 5.5 3 3.7 0 4.6-2.6 4.7-3.2.5-2.9-.5-3.6-2-4.5 0 0-.8-.4-1.9-.2"/>
|
||||||
|
<path d="M237 274.4a6.9 6.9 0 0 1-3.7 0c-2.9-.9-5.2-3.6-4-7m13.4-31.8c.3.3.4.7.4 1 .4 3.8-2.8 4.8-5 4.2a5.6 5.6 0 0 1-3-2.3 4.7 4.7 0 0 1-.7-2.3m22-23.6c.6.5 1 1 1.3 1.7m-1.1-8.5c.5.4.9.9 1.1 1.3"/>
|
||||||
|
<path stroke-linecap="round" d="M257.9 210.5a8.5 8.5 0 0 1-1.6 2.4 12.4 12.4 0 0 1-8.8 4c-2 0-4-.4-6-1.7a9.5 9.5 0 0 1-4-5.6"/>
|
||||||
|
<path d="M255.4 195.3a7.8 7.8 0 0 1 2.4 3.4"/>
|
||||||
|
<path stroke-linecap="round" d="M257.8 203.2c-.9 3-3.5 6.6-8.6 7.9-2.4.6-5.6-.2-6.6-1"/>
|
||||||
|
<path d="M240 202.6c.3 2.6 2 4.6 5.4 4.6 4.7.1 7.6-6.7 3.4-11.5"/>
|
||||||
|
<path stroke-linecap="round" d="M229.4 225.5c.7.9 1.5 1.7 2.4 2.4a16.8 16.8 0 0 0 6 3.3m5.2.5c4.2-.5 6.6-3.7 6-7.3-.3-2.8-2.8-5-4.6-5.1"/>
|
||||||
|
<path d="M249.8 188.1c1.9 0 3 1.6 2.9 3"/>
|
||||||
|
<path stroke-linecap="round" d="M249.4 163a11.5 11.5 0 0 0 5 5.9m144.2 31c1.7 2.3.6 7-4 7a5.2 5.2 0 0 1-4.5-2.5"/>
|
||||||
|
<path d="M381.7 169.1V185"/>
|
||||||
|
<path stroke-linecap="round" d="M243.8 202.3c1.4 1 3.3-.7 2.5-2.6-.5-1.2-2.2-2.6-4.7-.9-2.8 1.9-2 7.8 3.2 7.9 4.7 0 7.6-6.8 3.4-11.6-4-4.6-11.3-3.6-16 .2A21.4 21.4 0 0 0 225 207a22.5 22.5 0 0 0 0 9.2 20.9 20.9 0 0 0 3 7.5l1.3 1.7c.8.8 1 1.2 2 2a15 15 0 0 0 10.4 3.7c4.6-.2 7.3-3.4 6.8-7.3-.4-3.8-4.2-5.7-6.7-3.9-1.7 1.2-2.3 4.9.7 5.8 1.6.5 3.1-1.7 2-3M374 150.9c2.7-1.4 4.8-1.2 6.3 1a9.9 9.9 0 0 1 1.6 7.2 9.2 9.2 0 0 1-3.5 5.8"/>
|
||||||
|
<path stroke-linecap="round" d="M380.5 152c3.1-2 6.5-1.1 8.3 1.6 1.3 2 1.7 3.6 1.6 6.1a11.2 11.2 0 0 1-5.7 9.2"/>
|
||||||
|
<path d="M395 159.2c2.6.2 4.6 2.5 4.6 5.1 0 3.8-1 5.5-4 9.3-2.7 3.3-10.6 9-10.4 14.6 0 2.1 1.8 4 3.3 4.2"/>
|
||||||
|
<path stroke-linecap="round" d="M395.4 202.3c-1.5 1-3.3-.6-2.5-2.4.5-1.2 2.2-2.8 4.7-1.1 2.7 1.9 2 7.8-3.3 7.9-4.7 0-8-6.6-3.4-11.6 4-4.6 11.7-3.7 16.5.1 2 1.6 6.1 6 7 12 1 7 .9 15.6-6.4 21-3 2.1-7 3.1-10.6 3-4.6-.2-7.3-3.5-6.8-7.4.5-3.8 4-5.4 6.7-3.9 2.8 1.5 2.3 5.4-.7 5.8-1.7.2-3.1-1.7-2-3"/>
|
||||||
|
<path d="M392.9 199.9c.8-3.5 3.7-3.8 6.2-3.8 6.5.1 11.1 8 11.2 15.5 0 9.5-4 15.2-11 15.5-1.9 0-5-.8-5-3"/>
|
||||||
|
<path stroke-linecap="square" d="M397 198.3c6.9 1.6 9.3 7.8 9.3 13.8 0 4.9-.5 11.6-10 13.9"/>
|
||||||
|
<path d="M408.4 265.3a3.9 3.9 0 1 0-6.3 2.4"/>
|
||||||
|
<path stroke-linecap="round" d="M394.4 259.4c1.4 2 3 4.1 6.3 6m-1.3 10.5c-3.2-2.2-9.5-5-15-2.2a7.6 7.6 0 0 0-4.4 4.4 10 10 0 0 0 1.8 9.5c.9 1 2.7 2.6 5 2.7 3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.1-.7-.3-2.8-.2-3.2 1.3-.2.5-.2 1.3.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="M340.5 328.4c1 2.2-.2 3.2-1.6 3.4-2.2.3-3.3-1.4-3.4-3a4.4 4.4 0 0 1 4.3-4.7c2.3 0 4.1 1.5 5 3.5.3.7.5 1.5.5 2.4a5.8 5.8 0 0 1-1.4 4.1 7.5 7.5 0 0 1-5.4 2.5c-4.2.1-7.5-3.8-7.5-7.8 0-7.7 11.4-12 16-13a84 84 0 0 1 17.9-2.4c3.5-.1 6.2 0 10.1-.5 3.5-.3 5.4-.5 9-1.3a27.2 27.2 0 0 0 12.6-6.4c2.9-2.7 4.5-4.5 5.9-8.2a17 17 0 0 0-1.3-13.9 14.3 14.3 0 0 0-10.3-6.8c-3.7-.5-7 1.1-9 4.8-1 1.8-.6 4.8.1 6.2a6 6 0 0 0 4.8 3c3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.2-.7-.2-2.8-.1-3.2 1.4-.2.5-.2 1.3.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="M337.2 316.2c-4.8 2.1-8.4 3.7-11.4 8.5a9.9 9.9 0 0 0-1.2 4.9c0 2.7 1.1 5.7 3.3 7.6a8.3 8.3 0 0 0 6.7 2c2-.2 3.7-1.6 4-2.6"/>
|
||||||
|
<path d="M385.1 224.1c-2.3.8-3.9 4.2-3.9 7.5a8.4 8.4 0 0 0 4 7.5"/>
|
||||||
|
<path stroke-linecap="round" d="M387.6 236.4c-4 5.1-6.3 8.1-6.4 14.1 0 5.7 1.7 9.6 5.1 13.7"/>
|
||||||
|
<path d="m365.9 152 .3-.5c1.7-2.4 4.7-3.1 6.9-1.5 2.6 2 3.3 5.4 2.6 9-.5 2.2-2 4.1-4 5.5"/>
|
||||||
|
<path stroke-linecap="round" d="M265.1 150.8c-2.6-1.2-4.7-1-6.3 1a8.7 8.7 0 0 0-1.6 7.2c.6 2.7 1.4 3.8 3.5 5.8"/>
|
||||||
|
<path d="M258.6 152a5.8 5.8 0 0 0-8.3 1.6 9.1 9.1 0 0 0-1.6 6.1c.2 4.2 2.8 7.6 5.8 9.2"/>
|
||||||
|
<path d="M249.7 154.8a6.8 6.8 0 0 0-6 6.6c0 4.5 1 6.3 3.5 9.6 2.5 3.4 10.7 9.6 10.7 16.7 0 4.3-1.2 7-4.3 8.4-2 1-4.3 0-5.4-1-2.6-2.4-1.5-6.5 1.2-7 3.3-.6 3.9 4.6.7 4.3"/>
|
||||||
|
<path d="M244 159.2c-2.5.2-5 2.3-5 5 0 3.8 1.5 5.6 4.6 9.4 2.6 3.3 10.1 9 9.9 14.5 0 2-1.5 4.6-2.9 4.3"/>
|
||||||
|
<path stroke-linecap="round" d="M238 236.1c1.3-2.9 4.4-1.6 4.6 0 .4 3.7-2.8 4.8-5.1 4.2a4 4 0 0 1-2.5-2 4.8 4.8 0 0 1-.4-3.7 4.9 4.9 0 0 1 .9-1.6 5 5 0 0 1 1.2-1.2c1-.6 1.9-.6 3.4-.6 5.5 0 10.4 6.5 12 13.4.6 2.2 1.3 7.3.3 12a22.4 22.4 0 0 1-5.8 11.3 25.8 25.8 0 0 1-10 5.8 7 7 0 0 1-3.9.1c-2.8-.9-4.6-3.5-3.2-7 1.2-2.6 5.4-4 7.3-.6.2.3.4.9.4 1.5 0 .9-.4 2-1 2.4-1.4.9-3.7.6-3.6-2"/>
|
||||||
|
<path d="M233.8 270.4c1 .4 1.6.3 2.9-.2l1.8-1c2.6-1.5 5.6-3.8 8.4-9.1a18.8 18.8 0 0 0 1.7-4.5c.3-1 .5-2.2.6-3.3a25.6 25.6 0 0 0-4.8-15.3c-1.1-1.6-2-2.5-4.2-2.6m-9.5 31a3.9 3.9 0 1 1 6.3 2.3"/>
|
||||||
|
<path d="M232.2 261.4a3.7 3.7 0 0 1 3.7-3 3.7 3.7 0 0 1 3.6 3.7 3.8 3.8 0 0 1-1 2.6"/>
|
||||||
|
<path d="M239.4 261.3a15.5 15.5 0 0 0 6.2-12.4c0-4.1-1.6-8.4-3.6-10"/>
|
||||||
|
<path stroke-linecap="round" d="M244.7 259.4a16.5 16.5 0 0 1-6.3 6"/>
|
||||||
|
<path d="M254.6 273.7c-1-2.2-2.8-3.2-5.8-3.5-3-.3-5.5.5-8.2 1.9a18.6 18.6 0 0 0-10.8 17 25 25 0 0 0 2 9.5c.9 1.6 3 9 15.3 14a86.1 86.1 0 0 0 25.7 3.9c10.4.4 20 .8 25.6 7.6"/>
|
||||||
|
<path stroke-linecap="round" d="M239.7 275.9c3.3-2.2 9.5-5 15.1-2.2a8 8 0 0 1 4.3 4.4 10 10 0 0 1-1.8 9.5c-.9 1-2.7 2.6-5 2.7-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4.2.5.2 1.3-.2 2"/>
|
||||||
|
<path d="M252.7 285.7c.3-1 .2-2.2-.8-3.3a5.1 5.1 0 0 0-6-1c-.7.5-1.6 1-2.4 2.2-.4.4-1 1.1-1.2 1.6-.7 1.1-1 2-1 2.5-2.5 7 1.5 14.4 6.5 17.6 4.4 2.8 8.8 3.6 14.4 4 2.5.3 4 .3 6.5.5h9.6a70.1 70.1 0 0 1 7.2 0c3 .3 5.1.4 7.6.8 1.6.2 3.5.5 5.4 1 .6 0 1.2.2 1.8.4l1.2.3c3.6 1.1 7 2.4 9.8 4.2.8.5 1.8 1 2.5 1.7l1.3 1.2c2 2 4 4 4.4 6.7v1.6c0 1.8-1.4 4.3-5.3 5"/>
|
||||||
|
<path d="M298.6 328.4c-1 2.2.2 3.2 1.6 3.4 2.2.3 3.3-1.4 3.5-3a4.4 4.4 0 0 0-4.4-4.7 5.5 5.5 0 0 0-5 3.5 6.9 6.9 0 0 0-.5 2.4 5.8 5.8 0 0 0 1.4 4.1 7.5 7.5 0 0 0 5.4 2.5c4.2.1 7.5-3.8 7.5-7.8 0-7.7-11.4-12-16-13a84 84 0 0 0-17.9-2.4c-3.5-.1-6.2 0-10.1-.5-3.5-.3-5.4-.5-9-1.3a27.2 27.2 0 0 1-12.5-6.4 17 17 0 0 1-4.7-22 14.3 14.3 0 0 1 10.3-6.9c3.8-.5 7 1.1 9 4.8 1 1.8.6 4.8-.1 6.2a6 6 0 0 1-4.8 3c-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4.2.5.2 1.3-.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="m273.3 152-.4-.5c-1.7-2.4-4.7-3.1-6.9-1.5-2.6 2-3.3 5.4-2.5 9a9 9 0 0 0 4 5.5"/>
|
||||||
|
<path d="M366.8 159.6c-4 4.4-8.1 5.8-14.1 6-2 0-5.5-.6-7.6-2.1-1.3-1-2.8-2.6-1.9-5.5.6-1.9 3.7-7.2 3.8-10.9.3-5.6-1.9-8.7-5.3-9.9-6.2-2.2-13 4-17 5.4-2.1.7-3.2.8-5.1.8-2 0-3-.1-5.2-.8-4-1.4-10.7-7.6-17-5.4-3.4 1.2-5.5 4.3-5.3 10 .1 3.6 3.2 9 3.8 10.8 1 2.9-.5 4.5-1.9 5.5-2 1.5-5.7 2.1-7.5 2-6-.1-10.1-1.5-14.1-5.9"/>
|
||||||
|
<path stroke-linecap="round" d="M297.3 314.4c.8.3.2-.2 5.3 2a22 22 0 0 1 11.3 8.9 10.5 10.5 0 0 1 .9 7.3"/>
|
||||||
|
<path d="M297.7 336a8 8 0 0 0 3.2.9c4.2.1 7.5-3.8 7.5-7.8 0-2.8-1.5-5.2-3.6-7"/>
|
||||||
|
<path stroke-linecap="round" d="M298.6 328.4c-1 2.3.4 3.5 1.8 3.7 2.2.2 3.4-1.4 3.6-3a4.5 4.5 0 0 0-2.2-4.2"/>
|
||||||
|
<path d="M390.1 154.8c3.2 0 6 3.6 6 7.2 0 4.3-2.2 6.9-3.9 8.8-1.3 1.6-2.7 3-4.4 4.7"/>
|
||||||
|
<path stroke-linecap="round" d="M386.3 151.4a9 9 0 0 1 2.8 2.4c1.3 2 1.7 3.7 1.6 6.2-.2 4.2-3.2 7.1-6 9m-4.7-17.6.6.7c1.9 2.2 2 5.4 1.6 7.2a8.2 8.2 0 0 1-3.8 5.4m-5-14.4c2.6 2 3.4 5.4 2.5 9-.6 2.5-2.2 4-4.2 5.2m11.1 41.1c.3 1 .9 1.3 1.5 2a13.5 13.5 0 0 0 6.2 3.5c2.4.7 4.6.2 6.3-.9m-163 54c1.2 0 2.5.9 3.3 2.3.1.2.4.8.4 1.5 0 .9-.4 1.8-1 2.2-1.5 1-4 .5-4-2"/>
|
||||||
|
<path d="M241.5 231.3c5 1 9.7 6.9 11.2 13.3.6 2.3 1.3 7.3.3 12a22.4 22.4 0 0 1-6 11.4 16.5 16.5 0 0 1-2.1 1.9l-1 .7m-8-12.1c2 0 3.8 1.9 3.8 4a3.8 3.8 0 0 1-1 2.6"/>
|
||||||
|
<path d="M234.6 260.7c2.1 0 4.1 2 4.1 4.2a3.9 3.9 0 0 1-1.4 3"/>
|
||||||
|
<path stroke-linecap="round" d="M254 239.5a18 18 0 0 1 3.8 7.7m0 8.5a17.3 17.3 0 0 1-1.5 4 17.8 17.8 0 0 1-3.6 4.7"/>
|
||||||
|
<path d="M254.3 224.3c1.8 1.5 3 3 3.5 4.8"/>
|
||||||
|
<path stroke-linecap="round" d="M257.9 219.5a10 10 0 0 1-3.4 4.6m-9.2-17.2 2.2-.6 1.3-1 .8-1.1.7-1.8.3-1.5"/>
|
||||||
|
<path d="M241 199.3c-.7.2-1.6.4-2.5.8a9 9 0 0 0-3.5 3 17 17 0 0 0-2.2 12.7 15.8 15.8 0 0 0 2.3 5.6l1 1.4c1.4 1.3 2.6 2 4.6 1.7"/>
|
||||||
|
<path stroke-linecap="round" d="M253 189.8c-.3 1.3-1 2.9-3 2.7"/>
|
||||||
|
<path d="M245.7 198.5c-2-1.9-6-2.4-10.1.2L234 200a8.8 8.8 0 0 0-1.4 1.6 17.5 17.5 0 0 0-2.4 5c-.7 3-.7 5.6-.6 6.3 0 1 .2 1.9.3 2.7.6 2.8 1.4 4.8 2.3 6.2.9 1.5 3 5 7.7 5.4 1.8.1 4.8-.7 5-3"/>
|
||||||
|
<path stroke-linecap="round" d="M363.8 157c.3-1.6 2.3-1.9 3-1 1.2 1.6.4 4.2-2 4.9a4 4 0 0 1-3.8-1.4c-1-1.3-.9-2.8-.5-4 .2-.8.9-1.5 1.7-2.2 2.7-2 7.1-1.6 8.6 2 1.8 4.4-2.2 7.8-6 10.3-4.6 3.2-10 3.8-14 3.7-9.2 0-16.1-4.4-20.7-7-1-.5-2.1-.4-2.7.3a2 2 0 0 0 .3 2.7"/>
|
||||||
|
<path stroke-linecap="round" d="M365.6 155.5c1 0 1.2.4 1.5.8 1.2 1.5.3 4.1-2 4.9m17.8 51.5c-3.5 3.8-.2 10.3 2.4 11.8.9.7 1.3.3 2 .7"/>
|
||||||
|
<path d="M383.1 205.4c-1.1.8-1.5 1.7-1.6 3.3a5.3 5.3 0 0 0 1.4 4 14 14 0 0 0 9.3 3.7c2 0 4-.4 6-1.7a9 9 0 0 0 3.8-5.4m-20.8 61.8-.2 2.5a18.9 18.9 0 0 1-2 7 18.7 18.7 0 0 1-4.2 5.6 19.6 19.6 0 0 1-5.9 4 24.6 24.6 0 0 1-6.5 2.3 43.8 43.8 0 0 1-13.2.6c-2.7-.2-4.1-.5-6.8-.8-2.2-.1-3.4-.3-5.6-.3a28.3 28.3 0 0 0-10.9 1.9c-2.7 1-5.7 3-6.4 3.8-.6-.9-3.7-2.8-6.3-3.8a22 22 0 0 0-5.2-1.5c-2.2-.4-3.5-.4-5.8-.4-2.2 0-3.4.2-5.6.4-2.6.2-4 .6-6.7.7-2.5.2-3.9.3-6.3.2a33 33 0 0 1-7-.8 24.6 24.6 0 0 1-6.5-2.2 19.6 19.6 0 0 1-5.8-4.1 18.7 18.7 0 0 1-4.2-5.7 19 19 0 0 1-2-6.9c-.2-1-.2-2.5-.2-2.5V169.3h123.2z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#c7b37f" stroke="#c7b37f">
|
||||||
|
<path stroke-width=".3" d="M248 285.6a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0zM232.5 268c0-1.3.8-2.3 1.8-2.3s1.7 1 1.7 2.3c0 1.2-.8 2.2-1.7 2.2-1 0-1.8-1-1.8-2.2z"/>
|
||||||
|
<path stroke="none" d="M241.3 223.6c0-1 .8-1.8 1.7-1.8 1 0 1.7.8 1.7 1.8s-.7 1.8-1.7 1.8-1.7-.8-1.7-1.8M272 158c0-1 .5-2 1.4-2 .9-.1 1.7.6 1.8 1.6 0 1-.5 2-1.4 2-.9.1-1.6-.6-1.8-1.6"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".6">
|
||||||
|
<path d="M239.3 234c-.4.1-.6.2-.8.5-.3.3-.4.4-.6.9l-.2 1.2m4.7 26.7 1-1 .6-1 .5-1 .7-1.3m-1.3 14-1.5.7-1.1.6a17.4 17.4 0 0 0-1.3.8l-1.2 1m15-37.9-.8-.8-1-.8-.9-.8"/>
|
||||||
|
<path stroke-linecap="butt" d="m254.2 225-1.2.5a5.1 5.1 0 0 1-1.5.3"/>
|
||||||
|
<path d="M237.4 208.4c.2.6.2 1 .5 1.5.2.7.5 1.1.9 1.7a8.3 8.3 0 0 0 2.6 2.7l1.5.8m-1-5.8 1.3.6a7.4 7.4 0 0 0 3 .6l1.8-.1m7.2-40.7-2-1.2c-.9-.5-1.3-.9-2-1.5a9.3 9.3 0 0 1-1.1-1.3l-.8-1.3m7.5-4.6.6 1.7a7.8 7.8 0 0 0 1.4 2c1 1 1.7 1.3 2.8 2.2m1.4-6c.3.7.3 1 .7 1.6.2.5.4.8.8 1.2l1.3 1.3c.7.6 1.2.7 2 1.1"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#703d29" stroke-width=".2" d="M333.3 151.6c0-1.7-1.7-1.8-2.4-1.8-1.8 0-2.3 1.1-4.6 2.3a11.9 11.9 0 0 1-6.7 2 12 12 0 0 1-6.7-2c-2.3-1.2-2.7-2.3-4.6-2.3a2.3 2.3 0 0 0-2.2 2.4v.9l.3.2c0-.8.1-1.2.5-1.7a2.2 2.2 0 0 1 1.6-.8c1.8 0 2.5 1.2 4.8 2.4 3 1.6 4.2 1.9 6.7 2a12 12 0 0 0 6.8-2c2.3-1.2 3-2.5 4.8-2.5.6 0 1 .4 1.3 1v.9l.2.1c0-.3.2-.4.2-1z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#703d29">
|
||||||
|
<path d="M264.4 294c.5-.5.9-.3 1-.6 0-.2 0-.2-.3-.3l-.9-.2-.8-.4c-.1 0-.4-.2-.5 0-.1.4 1 .4.6 1.4a3.7 3.7 0 0 1-.8 1.2l-2.6 3-.2.1v-4.3l.1-1.8c.2-.4.8 0 .9-.4 0-.1 0-.2-.3-.3-.2 0-.5 0-1.1-.3l-1-.5c-.2 0-.5-.2-.6 0l.1.3c.4.2.5.4.5 1v7.4c0 .5.1.6.2.7.1 0 .2 0 .4-.3z"/>
|
||||||
|
<path d="M267.5 295.2c.3-1.1 1-.4 1-.8.1-.2 0-.2-.2-.3l-1.3-.4c-.4 0-.8-.3-1.2-.4 0 0-.3-.1-.4 0-.1.5 1.1.5.8 1.5l-1.7 5.5c-.3 1-1 .6-1.1 1v.1l1.2.4 1.6.5h.3c.2-.4-1.2-.3-.7-1.7zm3.7 1c.2-.6.5-.5.9-.4 1 .3 1.4 1.3 1 2.5-.2.6-.4 1.2-2 .8-.3-.1-.7-.2-.6-.5l.7-2.3zm-2.8 5c-.5 1.4-1.2.8-1.3 1.2 0 .2.2.2.3.3l1.6.4.8.3h.4c.1-.5-1-.3-.7-1.5l.6-2c.1-.4.1-.5.6-.3.6.1.7.3.8.8l.3 2c.2.9.3 1.7 1 2 .5 0 1.2 0 1.4-.4l-.2-.2h-.3s-.3 0-.3-.3l-.7-3.6c0-.2.4-.2.8-.3a2 2 0 0 0 1-1.3c.1-.5.4-2.2-1.8-2.9l-2.1-.5-1.2-.4h-.3c-.1.5 1.1.4.7 1.7zm8.4 2.5c-.4 1.4-1.4.5-1.5 1 0 .2.1.3.3.3l1.5.3 1.4.4c.3 0 .5.2.6-.1 0-.3-1.3-.3-1-1.8l1.3-5.2c0-.6.2-.6.6-.5l1 .2c1.1.3.5 1.5 1 1.6.2 0 .2-.4.2-.6l.1-1v-.4l-3.3-.7-3.2-.8c-.1 0-.2 0-.2.2l-.5 1.5c-.1.1-.2.4 0 .4.5.1.5-1.5 1.7-1.2l.9.2c.4.1.5.2.4.8zm12.7-3.3c.4-.6.8-.5.9-.7 0-.2-.2-.2-.4-.3h-.9l-.9-.3c-.1 0-.4-.1-.4.1-.1.4 1 .2.8 1.3 0 .2-.1.6-.6 1.3l-2 3.3-.3.2v-.2l-.7-4a5.4 5.4 0 0 1-.1-1.8c0-.5.7-.2.7-.5 0-.2 0-.2-.4-.3l-1.1-.1c-.4 0-.7-.2-1-.3-.2 0-.5-.1-.6.1l.1.2c.5.2.6.4.7.9l1.3 7.3c.1.5.2.7.3.7.1 0 .2 0 .4-.3zm.6 6.8c0 .3 0 .3.2.5.6.2 1 .6 1.7.7 1.4.2 2.6-.7 2.8-2.2.3-1.5-.3-2.1-1.4-2.9-1.3-.9-1.8-1.1-1.7-2 .1-.7.7-1 1.4-1 1.8.3 1.6 2.6 1.8 2.6.3 0 .3-.1.3-.4l.2-1.6v-.4h-.6c-.4 0-.7-.5-1.6-.7-1.2-.2-2.3.7-2.5 2-.2 1.2.4 1.8 1.2 2.4 1.6 1.1 2.2 1.4 2 2.4-.1 1-.9 1.4-1.7 1.3-1.2-.2-1.6-1.4-1.8-2.6 0-.2 0-.3-.2-.3s-.2.3-.2.5v1.7zm15.8-4.5c.3-.7.8-.6.8-.9 0-.2-.1-.1-.4-.2h-.9l-.9-.1c-.1 0-.4 0-.4.2 0 .4 1 0 1 1.1 0 .2-.1.6-.5 1.4l-1.8 3.5-.1.3-.1-.3-1.1-4a5.4 5.4 0 0 1-.3-1.6c0-.5.7-.3.7-.6 0-.2 0-.2-.4-.2h-1.2l-1-.2c-.2 0-.5-.1-.6.1l.2.2c.4.2.6.3.7.8l2.1 7.1.4.7c.1 0 .2 0 .3-.4z"/>
|
||||||
|
<path d="M307.6 308.5c0 1.2-1 1-1 1.5 0 .2.1.1.3.1h2.2l.4-.1c0-.6-1.4.2-1.4-2v-4.2l.1-.1.2.1 5.1 6.3.3.1.2-.3v-6.7c0-1.3 1-1 1-1.3 0 0 0-.2-.3-.2h-2.3c-.2 0-.2.1-.2.2 0 .4 1.3.2 1.3 1.3v4l-.1.4-.4-.3-4.2-5.3c-.2-.3-.1-.3-.4-.3h-1.8l-.2.1c0 .6 1.2-.2 1.2 2.1zM318 303c0-1.1.8-.7.8-1.1 0-.1 0-.2-.4-.2h-2.6s-.3 0-.3.2c0 .4 1.1 0 1.1 1.2v5.7c0 1.1-.8.8-.8 1.2 0 0 0 .2.2.2h2.8c.2 0 .3 0 .3-.2 0-.4-1.2.2-1.2-1.3zm4.5 5.5c0 1.5-1.2 1-1.2 1.4 0 .2.2.2.4.2h3c.3 0 .5 0 .5-.3s-1.4 0-1.4-1.4V303c0-.6 0-.6.5-.6h1c1.2-.1.8 1.2 1.3 1.2.2 0 .1-.4.1-.6l-.1-1c0-.2 0-.4-.2-.4l-3.3.1h-3.3l-.2.3-.1 1.6.1.4c.5 0 .2-1.6 1.4-1.6h.9c.4 0 .5 0 .6.6v5.6zm6.3-2.2h-.4l.1-.5.7-2.2v-.2l.2.1 1 2.1.2.4c0 .2-.2.2-.4.2zm1.8.5c.3 0 .3 0 .8 1l.2.8c0 .7-.7.6-.7 1 0 .1.2.1.4 0h1.2l1.3-.1c.3 0 .4 0 .4-.2 0-.4-.6 0-1-.7l-3.4-7-.3-.4c-.2 0-.2.2-.3.4L327 309c-.2.7-.8.7-.7 1h2.3c.2-.1.5 0 .5-.3s-1.2 0-1.3-.9l.2-1c.2-.8.4-.8.6-.8l2.1-.2zm8.3-5c-.1-.8 0-.8 1.2-1 2-.2 1.4 1.3 2 1.2.2 0 0-.4 0-.6l-.1-1.1c0-.1-.1-.2-.3-.2-1 0-1.7.2-2.4.3l-2.8.4c-.2 0-.3 0-.3.2.1.5 1.3 0 1.4 1l.7 5.5c.2 1.5-.7 1-.6 1.5 0 0 0 .1.2 0l1.4-.1 1.2-.1c.3 0 .5 0 .5-.3s-1.2.1-1.4-1.2l-.2-1.7c-.1-.7-.1-.9.3-1h.8c1.1-.2 1 1.1 1.3 1 .3 0 .2-.4.1-.5l-.3-2.1c0-.3-.2-.3-.2-.3-.3 0-.1 1.1-1 1.2l-.7.1c-.5.1-.5 0-.6-.5zm4 2.8c.4 2.3 2.1 3.7 4.2 3.3 3.4-.7 3.5-3.6 3.2-5.3-.5-2.5-2.3-3.7-4.4-3.3-2.5.5-3.5 2.7-3 5.3m1.1-1c-.3-1.6 0-3.4 1.7-3.7 1.4-.3 3 .8 3.4 3.4.3 2 0 3.6-1.8 4-1.9.4-3-2-3.3-3.6zm8.3-4.1c-.1-.7.2-.8.6-.9 1-.2 1.8.5 2.1 1.6.2.7.3 1.4-1.3 1.8-.3 0-.7.1-.8-.2l-.5-2.3zm0 5.7c.4 1.4-.5 1.3-.5 1.6.1.3.3.2.4.1.6 0 1-.3 1.6-.4l1-.2c.2 0 .2-.1.2-.2 0-.4-1 .3-1.3-1l-.5-2c0-.4-.2-.4.4-.5.5-.2.7-.1 1.1.3l1.3 1.6c.5.6 1 1.3 1.8 1.1.5-.1 1-.5 1-.9l-.2-.1-.3.1s-.3.1-.4 0l-2.4-2.9.5-.6c.2-.4.4-.9.2-1.6-.1-.5-.7-2.1-3-1.6l-2.1.6-1.2.2c-.2 0-.3.1-.2.2 0 .5 1.1-.2 1.4 1zm8.7-2c.3 1.4-1 1.2-.9 1.6 0 .3.3.2.5.2l1.4-.5 1.5-.3c.3 0 .5 0 .4-.4 0-.3-1.3.4-1.7-1l-1.3-5.3c-.2-.5 0-.6.3-.7l1-.2c1.1-.4 1.1 1 1.5.9.3 0 0-.5 0-.7l-.4-1s0-.3-.2-.2l-3.2.9-3.2.7v.3l.1 1.6c0 .2 0 .4.3.4.5-.1-.3-1.6 1-1.9l.8-.2c.4-.1.6 0 .7.5zm5.5-7.3c-.3-1 .6-.9.4-1.3h-.3l-1.4.4-1.2.3s-.3 0-.3.2c.1.4 1.2-.2 1.5.8l1.6 5.6c.2 1-.6 1-.5 1.3 0 .1 0 .2.2.1l1.1-.3 1.6-.4c.3 0 .3-.1.3-.3-.1-.3-1.1.5-1.5-.9zm2.3 2.7c.7 2.3 2.6 3.4 4.7 2.7 3.2-1.1 3-4.1 2.4-5.7-.8-2.4-2.8-3.3-4.8-2.7-2.4.9-3.2 3.2-2.3 5.7m1-1c-.6-1.7-.6-3.5 1.1-4 1.3-.5 3 .4 3.9 2.9.6 1.8.5 3.6-1.2 4.2-1.8.6-3.2-1.5-3.8-3.2zm7.6-5.5c-.2-.7 0-.8.4-1 1-.3 2 .3 2.4 1.4.2.6.4 1.3-1.1 1.9-.3 0-.7.2-.8 0zm.8 5.6c.6 1.4-.4 1.4-.2 1.7 0 .3.2.1.4.1l1.5-.7.9-.2c.2-.1.2-.2.2-.3-.2-.4-1 .4-1.4-.8l-.8-1.9c-.2-.4-.2-.5.3-.7.5-.2.7-.1 1.1.3l1.6 1.4c.5.5 1.1 1.1 2 .8.3-.2.9-.7.7-1l-.2-.1-.2.2h-.5l-2.8-2.5.4-.7a2 2 0 0 0 0-1.6c-.1-.6-1-2-3.1-1.2l-2 .9-1.2.4-.2.2c.2.4 1.1-.4 1.6.8l2 5z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fedf00" transform="matrix(.64 0 0 .64 0 16)">
|
||||||
|
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
|
||||||
|
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13.2 13.2 0 0 1-3.8 4c-2 1.2-4 1.8-6 1.6-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6-1.4 1-1.4 2.8-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
|
||||||
|
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
|
||||||
|
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6.3 6.3 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5-2.3-.2-4 1-5 3-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a8.8 8.8 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
|
||||||
|
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8 4.9 0 7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
|
||||||
|
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#d52b1e">
|
||||||
|
<path fill="#fedf00" d="M322.3 175.5h52.6V228h-52.6z"/>
|
||||||
|
<path d="M329.7 175.5h7.8V228h-7.8zm15 0h7.8V228h-7.8zm15 0h7.9V228h-7.9z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
|
||||||
|
<path fill="#fedf00" stroke="none" d="M264.3 273.5c.1 1 .5 2.6 1.4 4.3 1 1.5.6 1.4 2.7 3.8a15.3 15.3 0 0 0 4 2.9 32.7 32.7 0 0 0 15 2.6c2.7-.1 4.8-.4 6.6-.7a71 71 0 0 1 11-.6c1.5 0 3 .3 4.7.6 3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4z"/>
|
||||||
|
<path stroke-width=".3" d="m270.4 283.1 2.5 1.5 3.4 1.2v-52.2h-5.9zm29.2 2.4v-51.9h-5.8v52.8l5.8-.7zm11.7-51.9h-5.8v52.1c1.9.2 3.8.6 5.8 1zm-23.4 0V287s-3.8.2-5.8 0v-53.4z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(.64 0 0 .64 0 16)">
|
||||||
|
<path fill="#fedf00" d="M585.5 402.4a20.8 20.8 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26.3 26.3 0 0 1-13 7 51.8 51.8 0 0 1-16.6 1.6c-4.3-.2-7.5-.7-10.3-1-3.8-.6-6.7-.9-11-1a62.9 62.9 0 0 0-6.2 0 83.3 83.3 0 0 0-18.3 4.2V340h82.2v58.5z"/>
|
||||||
|
<g id="ad-b">
|
||||||
|
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8c-.4.4-.7.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1-.1.4-.3.6-.6 1-.4.4-.7.5-1 1l-1.2 1-.3.1h-.6c-.4.2-.5.6-.8.8l.3.6.8 1.4c.2.3.2.7.5.8.5.2.9.2 1.3.1.8.2 1.3.2 2 .5l1.5.8c.5.3.8.4 1.3.5h1.8v.3l2 1a1.7 1.7 0 0 0-.1.4c-.1.3-.2.7-.1.8.6 1.9 1.2 3 1.5 3.2.6.2.8.9 1.1 1.5l-.3.3c-.6.6-1.2 1-1.7 1.8-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4c.4.7.6 1.2.8 2 .2.7.3 1.2.3 2l1 .3.7-.6.6-1.2v-1c-.2-.1-.3-.4-.2-.7 0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9 0-.2 0-.4.4-.7l2-1.8c.2.1.6.2 1 .1l1.3.4c.6.2.9 0 1.2 0h.4l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7c0 .4-.2.7-.5 1-.2.4-.6.4-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5c.4-.2.8 0 .9-.1.2-.3 0-.4 0-.8 0-.6-.2-1-.3-1.6a11.8 11.8 0 0 1-.1-2.8c0-.6 0-1 .2-1.5.1-1 .4-1.4.6-2.2.3-1 .3-1.6.4-2.5a24.4 24.4 0 0 0 10.1-.6c.8.7 1.7 1.2 2.7 1.6v1c0 .3 0 .4.2.7l.3.3c.3 0 .5 0 .7-.2.2-.2.2-.4.2-.7v-.7h1.8v1.1c.1.3.3.4.5.4a.7.7 0 0 0 .6 0c.3-.2.2-.6.3-1v-.7l1-.4a5.1 5.1 0 0 1 0 .9l-.3.9c-.2.6-.5.8-.8 1.4-.4.6-.5 1-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2c0-.3 0-.6.3-.8.2-.3.4-.3.7-.4.4 0 .8 0 1-.2.4-.3.4-1 .7-1.5a12.7 12.7 0 0 1 3-3.9l1.7-1.4c.2-.4.5-.5.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9.1.9-.1 1.4-.3 2.3-.1.8-.1 1.3-.5 2a3.8 3.8 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7.4-.1.7 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6-.5-.7-.7-1-.7-1.5V362a42.7 42.7 0 0 1 0-2.8l.4-.2c1.2-.7 1.7-.9 2.4-2.5a3.4 3.4 0 0 0 .3-1.5v-1l-.4-1a3.2 3.2 0 0 0-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1a67 67 0 0 1-3.8.2l-2.2-.7c.9-.3 1.1-.5 1.5-1 .3-.4.2-.7.6-1.1l.7-1a2.2 2.2 0 0 0-.9-.4h-1a3 3 0 0 0-1.2.3l-.8.6-2.2-1.2a8.8 8.8 0 0 0-3-.9zm2 11.8"/>
|
||||||
|
<g fill="none" stroke="#fedf00" stroke-linecap="round">
|
||||||
|
<path d="m568.8 359.5-.8.3c-.9.4-1.6.4-2.6.5-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6a9.3 9.3 0 0 1-1.7.2l.5-1s-1.2.3-2 .3a7.5 7.5 0 0 1-1.6-.2l1-1-1.3-.2a4 4 0 0 1-1-.7 20.5 20.5 0 0 0 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3.8-.3 1-1 1.1-1.8.1-.8-.4-1.4-.8-1.8-.1 0-.5-.3-1.1-.4"/>
|
||||||
|
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6c-.5 0-.9 0-1.3.3-.5.3-.6.7-1 1.1.5.1.8.4 1.2.3.4 0 .5-.2.8-.5.3-.4.4-.7.4-1.2z"/>
|
||||||
|
<path d="M536 363.8a13.6 13.6 0 0 0 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
|
||||||
|
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8.2 8.2 0 0 0 2.7-1.8"/>
|
||||||
|
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
|
||||||
|
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2c.3 0 .5 0 .7.2.2 0 .2.1.3.3 0 .2.2.3.1.5 0 .2-.3.4-.6.4-.2 0-.4 0-.5-.3a.5.5 0 0 1 0-.4 1 1 0 0 1-.9 0 1 1 0 0 1-.6-.5z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#0065bd" d="m525.1 364.2-2-.9c.4-.2.7-.2 1-.5.3-.4.3-.8.5-1.3s.2-1 .7-1.4c.3-.2.8-.2 1.1-.1.4 0 .8.4.9.7 0 .6-.2 1-.3 1.5 0 .6-.3.9-.2 1.4 0 .4.2.6.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2c-.4-.4-.4-.8-.6-1.2a4 4 0 0 1-.3-1.2v-2c0-.3 0-.6-.2-.9 0-.2-.4-.3-.3-.4 0-.1.3 0 .4 0 .4 0 .6.1 1 .4.3.3.5.6.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9.2 9.2 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2-.8 1.3-1.7 2.3-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a8.7 8.7 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2a8.4 8.4 0 0 1-1.3 0h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1c0 .1.1.3 0 .4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8c-.6-.3-1-1-.6-1.6.1-.2.4-.2.6-.4.2-.3.1-.5 0-.8l-.1-1-.2-1c0-.6 0-1 .4-1.6.2-.3.7-.6.8-.6.2.1 0 .5 0 .8 0 .5.1.7.3 1.2l.7 1.3c.2.6.4.8.4 1.4 0 .5 0 .7-.2 1.2a2 2 0 0 1-.6.8 2 2 0 0 1-.8.4 1.1 1.1 0 0 1-.6 0z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
|
||||||
|
</g>
|
||||||
|
<path fill="none" stroke="#703d29" stroke-width=".5" d="M264.1 175.5h52.6V228h-52.6zm58.2 0h52.6V228h-52.6zm-58 98c.1 1 .5 2.6 1.4 4.3 1 1.5.6 1.4 2.7 3.8a15.3 15.3 0 0 0 4 2.9 32.7 32.7 0 0 0 15 2.6c2.7-.1 4.8-.4 6.6-.7a71 71 0 0 1 11-.6c1.5 0 3 .3 4.7.6 3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4zm110.4 0a13 13 0 0 1-1.4 4.3c-1 1.5-.6 1.4-2.7 3.8a15.4 15.4 0 0 1-4 2.9c-1.3.7-2.3 1-4.4 1.6a32.6 32.6 0 0 1-10.6 1c-2.7-.1-4.8-.5-6.5-.7a71 71 0 0 0-7.2-.6 40.5 40.5 0 0 0-3.9 0c-1.5 0-3 .3-4.7.6-3.5.7-7 2-7 2v-54.8H375v37.5l-.2 2.4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 33 KiB |
6
public/vendor/flag-icons/flags/4x3/ae.svg
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 640 480">
|
||||||
|
<path fill="#00732f" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#000001" d="M0 320h640v160H0z"/>
|
||||||
|
<path fill="red" d="M0 0h220v480H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
81
public/vendor/flag-icons/flags/4x3/af.svg
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="#000001" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#090" d="M426.7 0H640v480H426.7z"/>
|
||||||
|
<path fill="#bf0000" d="M213.3 0h213.4v480H213.3z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(1 27.3)scale(1.06346)">
|
||||||
|
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
|
||||||
|
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
|
||||||
|
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2-.8 0-1.7-.6-1.9-1.3-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4-.3.8-1.1 1.5-2 1.6-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
|
||||||
|
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
|
||||||
|
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6c0 2 .7 3.1 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
|
||||||
|
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
|
||||||
|
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
|
||||||
|
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
|
||||||
|
<path d="M315.4 210.3h1v7.1h-1z"/>
|
||||||
|
<g id="af-a">
|
||||||
|
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7 0 2 .2 3.9 0 5.8-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
|
||||||
|
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6.1 6.1 0 0 0-2.4-1.8 5.3 5.3 0 0 1-2.4-1.5 3.6 3.6 0 0 1-.8-1.5 5.9 5.9 0 0 1 0-2l-.3.3c-2.3 1.6-4 5.9-4 8.7a28.5 28.5 0 0 0 0 2.3c.2.5.3 1 .6 1.3l1.1.8 2.7.7a7.1 7.1 0 0 1 2.6 2 10.5 10.5 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
|
||||||
|
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2 1.8.7 3.7 3 4.5 4.8"/>
|
||||||
|
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
|
||||||
|
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
|
||||||
|
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3-.7 2-1 4.3-.6 6.1 0 .7.3 1.1.5 1.5.2.3.4.5.6.5.3 0 .6 0 .7-.3l.2-.8c-.1-2-.1-3.8.3-5.4a7.7 7.7 0 0 1 3-4.4c.3-.2.4-.5.5-.7a1 1 0 0 0-.3-.7c-.4-.3-1-.4-1.5-.1m.2.4c.4-.2.8 0 1 .1l.1.2c0 .1 0 .2-.3.4a8.2 8.2 0 0 0-3.1 4.6 16.7 16.7 0 0 0-.3 5.6 1 1 0 0 1-.2.6s0 .1-.2 0c0 0-.2 0-.4-.3a3.9 3.9 0 0 1-.4-1.2c-.3-1.8 0-4 .7-6 .7-1.8 1.8-3.4 3-4z"/>
|
||||||
|
<path fill="#bd6b00" stroke="none" d="M295.2 157.7c-1.5.7-2.5 2.3-3 4.2a13.6 13.6 0 0 0-.3 5.9c.2 1.3 1 2 1.6 2 .3.1.6 0 .8-.3.2-.3.3-.6.2-1-.4-1.6-.5-3.4-.3-5.1.3-1.7 1-3.2 2.2-4.1.3-.3.5-.5.5-.8a.8.8 0 0 0-.2-.6c-.4-.3-1-.4-1.5-.2m.2.5c.4-.2.8-.1 1 0l.1.3-.3.4a6.5 6.5 0 0 0-2.4 4.4c-.3 1.8-.1 3.7.2 5.2.1.4 0 .6 0 .8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7.5-1.8 1.5-3.3 2.8-3.8"/>
|
||||||
|
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17.4 17.4 0 0 0-4.9 6z"/>
|
||||||
|
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
|
||||||
|
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
|
||||||
|
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
|
||||||
|
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
|
||||||
|
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
|
||||||
|
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
|
||||||
|
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
|
||||||
|
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
|
||||||
|
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1.4-1.7.5-4.6 2-5.2 1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a14.6 14.6 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7-1.2-1.1-2.5-2.7-4-3.8a17.5 17.5 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
|
||||||
|
<path stroke="none" d="M220.6 183c-1.2-1.4-.9-1.8 1-1.9 1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
|
||||||
|
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
|
||||||
|
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
|
||||||
|
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
|
||||||
|
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5 3.6.7 6.5 2.5 8.9 5.3a94.4 94.4 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
|
||||||
|
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
|
||||||
|
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
|
||||||
|
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
|
||||||
|
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
|
||||||
|
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
|
||||||
|
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
|
||||||
|
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
|
||||||
|
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
|
||||||
|
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
|
||||||
|
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
|
||||||
|
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
|
||||||
|
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
|
||||||
|
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
|
||||||
|
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
|
||||||
|
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
|
||||||
|
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
|
||||||
|
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
|
||||||
|
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
|
||||||
|
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
|
||||||
|
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
|
||||||
|
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
|
||||||
|
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5.3 5.3 0 0 1 0-3.4 7.5 7.5 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4.6.9.7 2.8.2 3.7-.6 1.3-.4 3-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1 1.1-1.1 2.4.4 1.2 1.2-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4-.4-2.2-2-.8-1.3.9.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5.4-2 .4-3.6 0-5.3-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a9.9 9.9 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a8.6 8.6 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
|
||||||
|
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1.1 1.1 0 0 1 .7-1.2 33.4 33.4 0 0 1 2.6-.8c1-.3 1.6.4 1.6.9v2c0 .7-.2.8-.7.9-.7.1-1.7.2-2.4.7-.6.4-1.2.1-1.5-.5zm10.6 0c0-.6-.2-1.1-.6-1.2a5.4 5.4 0 0 0-2.4-.4c-1 0-1.1.2-1.1.6v2.1c0 .8 0 .8.4 1 .7 0 1.8 0 2.5.6.5.3 1 0 1.1-.6z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
|
||||||
|
<g stroke="none">
|
||||||
|
<path d="M328.5 286.6c0 1.2.2 2.2 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a6.5 6.5 0 0 0-5-2.9zm3.8-21.7c-1.3-.5-2.7 0-4 1.4-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6 0-2 1.5-3.4 2.4-3.5 2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a66.5 66.5 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
|
||||||
|
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2.3 2.3 0 0 0-1.5 0c-.5.1-1 .4-1.3.7a115.5 115.5 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1.5-.2 1-.5 1.3-1a1 1 0 0 0 .1-.8 2 2 0 0 0-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4-.3-.5-.9-.6-1.5-.5zm0 .5c.5 0 1 0 1.1.3 3 4.3 7.7 7 11.9 10.5l.4.7a.5.5 0 0 1 0 .4c-.1.3-.6.6-1 .7-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4 4.2-3.2 8.2-6.8 11.8-10.4a2.5 2.5 0 0 1 1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3a6.7 6.7 0 0 1 1.5-.4z"/>
|
||||||
|
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a6.6 6.6 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7-.5.3-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8-.3 0-.6-.4-.4-.7zm4.3.3a8.3 8.3 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7c-.3.2-.1.7.1.8.6.2 1.5.2 2 0 .6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a33.7 33.7 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5-1.8.2-3.2 0-4.8.6-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2c.6-.5 1-1.3 1.5-1.8.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3 1.3 0 2.4 0 2.4-1.2 0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3a75.6 75.6 0 0 0-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47.1 47.1 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17.2 17.2 0 0 0-5-6.3"/>
|
||||||
|
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3-1.2 2.6-3.7 3.6-7.5 3.2"/>
|
||||||
|
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1.2 1.2 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6-.5.8-1 .7-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4a614.3 614.3 0 0 1-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
|
||||||
|
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a5.5 5.5 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
|
||||||
|
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3-.3-.3-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3-.2.6 0 1.2-.1 1.6-.2.2-.6.4-1 .2-.2-.2 0-.7-.6-1-.2 0-.6-.1-.8.2-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2-.9 0-3-2.7-3.7-4-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2a7 7 0 0 0-1.5 1.3m4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a3.7 3.7 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a3.5 3.5 0 0 0-1.2 2c0 .2.3.6.5.5a3.2 3.2 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4-.4-1-1.5-1-1.7-.7-1 1.3.8 1 .5 1.4-.5 1-1 1.6-1.3 2.6-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4.8-2-1.9-5.9-2.7-8z"/>
|
||||||
|
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
|
||||||
|
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25.4 25.4 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3 1.1-.7 1.7-.6 2.4-.4.3.1.4.5.2.7"/>
|
||||||
|
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2-.6.9-.9 2-1.7 2-.7 0-2-1.5-1.3-1.5 2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7 0 .8-.5 1.7-1.3 1.6-.4-.1.2-1.9-.2-2.4a.5.5 0 0 0-.7 0c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5c-.2.9-.2 1.6-.2 2.3 0 .5 1 .2 1 .1 0-.8.2-2 0-2.3-.2-.1-.7-.3-.8-.1"/>
|
||||||
|
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6c4.9 0 9.3 1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path fill="#fff" d="m311.3 295-.3 2.6h-.4l-.1-1.8a9.3 9.3 0 0 0-.5-1.6 7.3 7.3 0 0 0-.5-1.3l-1-1.4.8-2.2a6.6 6.6 0 0 1 1.5 2.4 9.4 9.4 0 0 1 .5 3.2m7-4.2c0 .7-.2 1.2-.5 1.5-.2.3-.6.6-1.3.7l.4 1.5a6.7 6.7 0 0 1 0 2 22.5 22.5 0 0 1-.1 1.3h-.4a8.2 8.2 0 0 0-.1-1.3 5.5 5.5 0 0 0-.2-1l-.4-1a10.5 10.5 0 0 0-.7-1.4l-1-1.7.6-2 1 1c.3.2.6.3 1 .3.8 0 1.2-.4 1.2-1.3h.4v1.4m6.4 4.8-.5 2.1c-.4 0-.6-.3-.8-.7l-.4-1.3a12.4 12.4 0 0 1-.1-1.7 4 4 0 0 1-1 .2 2 2 0 0 1-1.3-.4 1.3 1.3 0 0 1-.5-1c0-.9.3-1.7.7-2.3.5-.7 1-1 1.5-1.1.5 0 .8.1 1 .4a2 2 0 0 1 .3.9v2c0 .9.1 1.5.3 1.9 0 .3.3.6.8 1m-2-3.5c0-.6-.3-.8-.8-.8a1 1 0 0 0-.6.1c-.2.1-.2.2-.2.3 0 .3.3.5 1 .5zm8.7 3-.3 2.6c-.5-.4-1-1-1.4-2a25.4 25.4 0 0 1-1.3-4.1 52.8 52.8 0 0 1-1.8 5.5 2.9 2.9 0 0 1-.8.7v-2.5c.6-.7.9-1.1 1-1.5a7.6 7.6 0 0 0 .8-1.7l.5-2.7h.4l.9 2.7c.2.6.5 1.2.9 1.6l1 1.4"/>
|
||||||
|
<path fill="#bf0000" d="M350.8 319.4c.4.4.6.8.7 1.2l.4 1.6-.8.1a7.8 7.8 0 0 0-1-1.5 18.8 18.8 0 0 0-1.1-1.2 46 46 0 0 0-1.7-1.5 34.4 34.4 0 0 0-2-1.7c-.4-.2-.6-.4-.6-.5a1.9 1.9 0 0 1-.3-.8 11.2 11.2 0 0 1-.2-1.6l2.7 2.2a44.3 44.3 0 0 1 2.5 2.2zm-9.5-5.8-.2 2H338l.3-2zm8.4 8.9-7.6 2.3-1.3-2 6.5-2-.7-.8a2.8 2.8 0 0 0-.9-.6 1.4 1.4 0 0 1-.4 1 2 2 0 0 1-1 .6 3.4 3.4 0 0 1-1.8 0 2 2 0 0 1-1.3-.7 4 4 0 0 1-.7-2.2c0-1 .3-1.6.9-1.8.7-.2 1.8 0 3 .7a8.1 8.1 0 0 1 3 2.4zm-5.8-4a3.8 3.8 0 0 0-.8-.3 1.1 1.1 0 0 0-.6 0 .7.7 0 0 0-.5.3.5.5 0 0 0 0 .6l.5.2h.6l.4-.3zm-8-1.6-.5 2-3.2-.3.5-2zm7.5 7.7-1.7.4a5.3 5.3 0 0 1-1.7 0 3.6 3.6 0 0 1-1.5-.4c-.3.5-.8 1-1.5 1.2a7.4 7.4 0 0 1-1.6.6l-1.2.3-1-2 1.1-.3a9.1 9.1 0 0 0 1.3-.4l.9-.5-1-.5h-.7a.4.4 0 0 0-.2 0 .6.6 0 0 0-.2.3h-.5c-.5-.8-.6-1.5-.3-2s.9-.8 2-1a6.8 6.8 0 0 1 2.6-.2c.8.1 1.3.4 1.5.9.2.2.2.5.2.7l-.4 1.2h.5a2 2 0 0 0 .6 0l1.7-.3zm-8 1.8-1.6.3a3 3 0 0 1-2.2-.4 5.5 5.5 0 0 1-1.7-2.6l-.8-2.2a2 2 0 0 0-.8-1 4.6 4.6 0 0 0-.9-.5l.6-2.1c.6.3 1 .6 1.4 1l1 1.7.5 1.5 1.1 2.2c.3.3.7.4 1 .3l1.7-.2zm-7-7.5-1 1.9-3-.7 1-1.9zm1.8 8.4-7.5.7-.4-2 6.2-.7a2.3 2.3 0 0 0-.6-.8 8.3 8.3 0 0 0-1-.6l.5-2c.7.4 1.2.9 1.6 1.3.3.5.6 1.2.8 2.1zm-6 1a17 17 0 0 1-2.2-.2 10.5 10.5 0 0 1-1.7-.5 5.6 5.6 0 0 1-1.3.4 9.9 9.9 0 0 1-1.7 0h-2a2.5 2.5 0 0 1-1.2-.3c-.3-.2-.5-.5-.8-1a4.1 4.1 0 0 1-1.5 1l-1.7.1h-1.7l.2-2.1h1.7c.8 0 1.5 0 2.1-.4a2 2 0 0 0 1.3-1.8l.7.1a30.2 30.2 0 0 0-.1 1.3c0 .3 0 .5.3.7.3.2.6.3 1 .3h1.5c1 0 1.6 0 2-.2.6-.2.9-.5 1-1.1l.1-.4s.3 0 .5-.2l.5-.2v.4a8.9 8.9 0 0 1 0 .3l-.3 1.1a12.4 12.4 0 0 0 2 .5c.1-.2 0-.4-.1-.7l-.3-.6a.5.5 0 0 1 .1-.3l.3-.2 1-.9.5 1v1zm-11.3-8.7-2 1.3-1.3-.9-1.4 1-1.9-1 1.8-1.3 1.5.8 1.5-1 1.8 1m-3 8.2-7.3-1.2.8-2 6.2 1c0-.4 0-.7-.2-1a5.2 5.2 0 0 0-.5-.8l1.6-1.7c.4.6.6 1 .7 1.6 0 .5-.2 1.2-.5 2.1zm-6.1-1-1.6-.3c-.9-.2-1.4-.6-1.5-1.2-.2-.6.1-1.5.8-2.8l1.2-2c.3-.5.4-.9.3-1.2a2.2 2.2 0 0 0-.3-.7l2.2-1.6c.3.5.3 1 .3 1.4 0 .5-.3 1.1-.7 1.8l-.8 1.4a5.8 5.8 0 0 0-.9 2.2c0 .4.2.6.5.7l1.6.4zm-3.8-8-2.5 1.1-1.8-1.7 2.6-1zm-1 6.6a6.8 6.8 0 0 1-1.6 1.4 4.2 4.2 0 0 1-1.7.6l-2.4-.1a14.8 14.8 0 0 1-2.8-.7 7.7 7.7 0 0 1-3.4-2c-.6-.8-.6-1.5 0-2.2a7 7 0 0 1 2-1.6c.8-.5 2-1 3.8-1.6l.4.5-2.8 1.2c-.5.3-1 .6-1.3 1-.4.4-.3 1 .2 1.6a10.5 10.5 0 0 0 6.3 2.2c1.2 0 2-.3 2.3-.7.3-.3.4-.6.5-1l.2-1.6 2.5-1.5a8 8 0 0 1-.1 1.5 4.4 4.4 0 0 1-1 1.6z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 21 KiB |
14
public/vendor/flag-icons/flags/4x3/ag.svg
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="ag-a">
|
||||||
|
<path fill-opacity=".7" d="M-79.7 0H603v512H-79.7z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(74.7)scale(.9375)">
|
||||||
|
<path fill="#fff" d="M-79.7 0H603v512H-79.7z"/>
|
||||||
|
<path fill="#000001" d="M-79.6 0H603v204.8H-79.7z"/>
|
||||||
|
<path fill="#0072c6" d="M21.3 203.2h480v112h-480z"/>
|
||||||
|
<path fill="#ce1126" d="M603 .1V512H261.6L603 0zM-79.7.1V512h341.3L-79.7 0z"/>
|
||||||
|
<path fill="#fcd116" d="M440.4 203.3 364 184l64.9-49-79.7 11.4 41-69.5-70.7 41L332.3 37l-47.9 63.8-19.3-74-21.7 76.3-47.8-65 13.7 83.2L138.5 78l41 69.5-77.4-12.5 63.8 47.8L86 203.3z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
29
public/vendor/flag-icons/flags/4x3/ai.svg
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ai" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<path id="ai-b" fill="#f90" d="M271 87c1.5 3.6 6.5 7.6 7.8 9.6-1.7 2-2 1.8-1.8 5.4 3-3.1 3-3.5 5-3 4.2 4.2.8 13.3-2.8 15.3-3.4 2.1-2.8 0-8 2.6 2.3 2 5.1-.3 7.4.3 1.2 1.5-.6 4.1.4 6.7 2-.2 1.8-4.3 2.2-5.8 1.5-5.4 10.4-9.1 10.8-14.1 1.9-.9 3.7-.3 6 1-1.1-4.6-4.9-4.6-5.9-6-2.4-3.7-4.5-7.8-9.6-9-3.8-.7-3.5.3-6-1.4-1.6-1.2-6.3-3.4-5.5-1.6"/>
|
||||||
|
</defs>
|
||||||
|
<clipPath id="ai-a">
|
||||||
|
<path d="M0 0v120h373.3v120H320zm320 0H160v280H0v-40z"/>
|
||||||
|
</clipPath>
|
||||||
|
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||||
|
<path stroke="#fff" stroke-width="50" d="m0 0 320 240m0-240L0 240"/>
|
||||||
|
<path stroke="#c8102e" stroke-width="30" d="m0 0 320 240m0-240L0 240" clip-path="url(#ai-a)"/>
|
||||||
|
<path stroke="#fff" stroke-width="75" d="M160 0v280M0 120h373.3"/>
|
||||||
|
<path stroke="#c8102e" stroke-width="50" d="M160 0v280M0 120h373.3"/>
|
||||||
|
<path fill="#012169" d="M0 240h320V0h106.7v320H0z"/>
|
||||||
|
<path fill="#fff" d="M424 191.8c0 90.4 9.7 121.5 29.3 142.5a179.4 179.4 0 0 0 35 30 179.7 179.7 0 0 0 35-30c19.5-21 29.3-52.1 29.3-142.5-14.2 6.5-22.3 9.7-34 9.5a78.4 78.4 0 0 1-30.3-9.5 78.4 78.4 0 0 1-30.3 9.5c-11.7.2-19.8-3-34-9.5"/>
|
||||||
|
<g transform="matrix(1.96 0 0 2.002 -40.8 62.9)">
|
||||||
|
<use xlink:href="#ai-b"/>
|
||||||
|
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(-.916 -1.77 1.733 -.935 563.4 829)">
|
||||||
|
<use xlink:href="#ai-b"/>
|
||||||
|
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(-1.01 1.716 -1.68 -1.031 925.4 -103.2)">
|
||||||
|
<use xlink:href="#ai-b"/>
|
||||||
|
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#9cf" d="M440 315.1a78 78 0 0 0 13.3 19.2 179.4 179.4 0 0 0 35 30 180 180 0 0 0 35-30 78 78 0 0 0 13.2-19.2z"/>
|
||||||
|
<path fill="#fdc301" d="M421.2 188.2c0 94.2 10.2 126.6 30.6 148.5a187 187 0 0 0 36.5 31.1 186.3 186.3 0 0 0 36.4-31.1c20.4-21.9 30.6-54.3 30.6-148.5-14.8 6.8-23.3 10.1-35.5 10-11-.3-22.6-5.7-31.5-10-9 4.3-20.6 9.7-31.5 10-12.3.1-20.7-3.2-35.6-10m4 5c14 6.5 22 9.6 33.5 9.4a76.4 76.4 0 0 0 29.6-9.4c8.4 4 19.3 9.2 29.6 9.4 11.5.2 19.4-3 33.4-9.4 0 89-9.6 119.6-28.8 140.2a176 176 0 0 1-34.2 29.4 175.6 175.6 0 0 1-34.3-29.4c-19.2-20.6-28.7-51.3-28.7-140.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
5
public/vendor/flag-icons/flags/4x3/al.svg
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 640 480">
|
||||||
|
<path fill="red" d="M0 0h640v480H0z"/>
|
||||||
|
<path id="al-a" fill="#000001" d="M272 93.3c-4.6 0-12.3 1.5-12.2 5-13-2.1-14.3 3.2-13.5 8 1.2-1.9 2.7-3 3.9-3.1 1.7-.3 3.5.3 5.4 1.4a21.6 21.6 0 0 1 4.8 4.1c-4.6 1.1-8.2.4-11.8-.2a16.5 16.5 0 0 1-5.7-2.4c-1.5-1-2-2-4.3-4.3-2.7-2.8-5.6-2-4.7 2.3 2.1 4 5.6 5.8 10 6.6 2.1.3 5.3 1 8.9 1 3.6 0 7.6-.5 9.8 0-1.3.8-2.8 2.3-5.8 2.8-3 .6-7.5-1.8-10.3-2.4.3 2.3 3.3 4.5 9.1 5.7 9.6 2 17.5 3.6 22.8 6.5a37.3 37.3 0 0 1 10.9 9.2c4.7 5.5 5 9.8 5.2 10.8 1 8.8-2.1 13.8-7.9 15.4-2.8.7-8-.7-9.8-2.9-2-2.2-3.7-6-3.2-12 .5-2.2 3.1-8.3.9-9.5a273.7 273.7 0 0 0-32.3-15.1c-2.5-1-4.5 2.4-5.3 3.8a50.2 50.2 0 0 1-36-23.7c-4.2-7.6-11.3 0-10.1 7.3 1.9 8 8 13.8 15.4 18 7.5 4.1 17 8.2 26.5 8 5.2 1 5.1 7.6-1 8.9-12.1 0-21.8-.2-30.9-9-6.9-6.3-10.7 1.2-8.8 5.4 3.4 13.1 22.1 16.8 41 12.6 7.4-1.2 3 6.6 1 6.7-8 5.7-22.1 11.2-34.6 0-5.7-4.4-9.6-.8-7.4 5.5 5.5 16.5 26.7 13 41.2 5 3.7-2.1 7.1 2.7 2.6 6.4-18.1 12.6-27.1 12.8-35.3 8-10.2-4.1-11 7.2-5 11 6.7 4 23.8 1 36.4-7 5.4-4 5.6 2.3 2.2 4.8-14.9 12.9-20.8 16.3-36.3 14.2-7.7-.6-7.6 8.9-1.6 12.6 8.3 5.1 24.5-3.3 37-13.8 5.3-2.8 6.2 1.8 3.6 7.3a53.9 53.9 0 0 1-21.8 18c-7 2.7-13.6 2.3-18.3.7-5.8-2-6.5 4-3.3 9.4 1.9 3.3 9.8 4.3 18.4 1.3 8.6-3 17.8-10.2 24.1-18.5 5.5-4.9 4.9 1.6 2.3 6.2-12.6 20-24.2 27.4-39.5 26.2-6.7-1.2-8.3 4-4 9 7.6 6.2 17 6 25.4-.2 7.3-7 21.4-22.4 28.8-30.6 5.2-4.1 6.9 0 5.3 8.4-1.4 4.8-4.8 10-14.3 13.6-6.5 3.7-1.6 8.8 3.2 9 2.7 0 8.1-3.2 12.3-7.8 5.4-6.2 5.8-10.3 8.8-19.9 2.8-4.6 7.9-2.4 7.9 2.4-2.5 9.6-4.5 11.3-9.5 15.2-4.7 4.5 3.3 6 6 4.1 7.8-5.2 10.6-12 13.2-18.2 2-4.4 7.4-2.3 4.8 5-6 17.4-16 24.2-33.3 27.8-1.7.3-2.8 1.3-2.2 3.3l7 7c-10.7 3.2-19.4 5-30.2 8l-14.8-9.8c-1.3-3.2-2-8.2-9.8-4.7-5.2-2.4-7.7-1.5-10.6 1 4.2 0 6 1.2 7.7 3.1 2.2 5.7 7.2 6.3 12.3 4.7 3.3 2.7 5 4.9 8.4 7.7l-16.7-.5c-6-6.3-10.6-6-14.8-1-3.3.5-4.6.5-6.8 4.4 3.4-1.4 5.6-1.8 7.1-.3 6.3 3.7 10.4 2.9 13.5 0l17.5 1.1c-2.2 2-5.2 3-7.5 4.8-9-2.6-13.8 1-15.4 8.3a17 17 0 0 0-1.2 9.3c.8-3 2.3-5.5 4.9-7 8 2 11-1.3 11.5-6.1 4-3.2 9.8-3.9 13.7-7.1 4.6 1.4 6.8 2.3 11.4 3.8 1.6 5 5.3 6.9 11.3 5.6 7 .2 5.8 3.2 6.4 5.5 2-3.3 1.9-6.6-2.5-9.6-1.6-4.3-5.2-6.3-9.8-3.8-4.4-1.2-5.5-3-9.9-4.3 11-3.5 18.8-4.3 29.8-7.8l7.7 6.8c1.5.9 2.9 1.1 3.8 0 6.9-10 10-18.7 16.3-25.3 2.5-2.8 5.6-6.4 9-7.3 1.7-.5 3.8-.2 5.2 1.3 1.3 1.4 2.4 4.1 2 8.2-.7 5.7-2.1 7.6-3.7 11-1.7 3.5-3.6 5.6-5.7 8.3-4 5.3-9.4 8.4-12.6 10.5-6.4 4.1-9 2.3-14 2-6.4.7-8 3.8-2.8 8.1 4.8 2.6 9.2 2.9 12.8 2.2 3-.6 6.6-4.5 9.2-6.6 2.8-3.3 7.6.6 4.3 4.5-5.9 7-11.7 11.6-19 11.5-7.7 1-6.2 5.3-1.2 7.4 9.2 3.7 17.4-3.3 21.6-8 3.2-3.5 5.5-3.6 5 1.9-3.3 9.9-7.6 13.7-14.8 14.2-5.8-.6-5.9 4-1.6 7 9.6 6.6 16.6-4.8 19.9-11.6 2.3-6.2 5.9-3.3 6.3 1.8 0 6.9-3 12.4-11.3 19.4 6.3 10.1 13.7 20.4 20 30.5l19.2-214L320 139c-2-1.8-8.8-9.8-10.5-11-.7-.6-1-1-.1-1.4.9-.4 3-.8 4.5-1-4-4.1-7.6-5.4-15.3-7.6 1.9-.8 3.7-.4 9.3-.6a30.2 30.2 0 0 0-13.5-10.2c4.2-3 5-3.2 9.2-6.7a86.3 86.3 0 0 1-19.5-3.8 37.4 37.4 0 0 0-12-3.4zm.8 8.4c3.8 0 6.1 1.3 6.1 2.9 0 1.6-2.3 2.9-6.1 2.9s-6.2-1.5-6.2-3c0-1.6 2.4-2.8 6.2-2.8"/>
|
||||||
|
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 640 0)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
5
public/vendor/flag-icons/flags/4x3/am.svg
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 640 480">
|
||||||
|
<path fill="#d90012" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#0033a0" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#f2a800" d="M0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
13
public/vendor/flag-icons/flags/4x3/ao.svg
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ao" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="red" d="M0 0h640v243.6H0z"/>
|
||||||
|
<path fill="#000001" d="M0 236.4h640V480H0z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#ffec00" fill-rule="evenodd" d="M228.7 148.2c165.2 43.3 59 255.6-71.3 167.2l-8.8 13.6c76.7 54.6 152.6 10.6 174-46.4 22.2-58.8-7.6-141.5-92.6-150z"/>
|
||||||
|
<path fill="#ffec00" fill-rule="evenodd" d="m170 330.8 21.7 10.1-10.2 21.8-21.7-10.2zm149-99.5h24v24h-24zm-11.7-38.9 22.3-8.6 8.7 22.3-22.3 8.7zm-26-29.1 17.1-16.9 16.9 17-17 16.9zm-26.2-39.8 22.4 8.4-8.5 22.4-22.4-8.4zM316 270l22.3 8.9-9 22.2-22.2-8.9zm-69.9 70 22-9.3 9.5 22-22 9.4zm-39.5 2.8h24v24h-24zm41.3-116-20.3-15-20.3 14.6 8-23-20.3-15h24.5l8.5-22.6 7.8 22.7 24.7-.3-19.6 15.3z"/>
|
||||||
|
<path fill="#fe0" fill-rule="evenodd" d="M336 346.4c-1.2.4-6.2 12.4-9.7 18.2l3.7 1c13.6 4.8 20.4 9.2 26.2 17.5a7.9 7.9 0 0 0 10.2.7s2.8-1 6.4-5c3-4.5 2.2-8-1.4-11.1-11-8-22.9-14-35.4-21.3"/>
|
||||||
|
<path fill="#000001" fill-rule="evenodd" d="M365.3 372.8a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.6 0zm-21.4-13.6a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0m10.9 7a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0"/>
|
||||||
|
<path fill="#fe0" fill-rule="evenodd" d="M324.5 363.7c-42.6-24.3-87.3-50.5-130-74.8-18.7-11.7-19.6-33.4-7-49.9 1.2-2.3 2.8-1.8 3.4-.5 1.5 8 6 16.3 11.4 21.5A5288 5288 0 0 1 334 345.6c-3.4 5.8-6 12.3-9.5 18z"/>
|
||||||
|
<path fill="#ffec00" fill-rule="evenodd" d="m297.2 305.5 17.8 16-16 17.8-17.8-16z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width="3" d="m331.5 348.8-125-75.5m109.6 58.1L274 304.1m18.2 42.7L249.3 322"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/vendor/flag-icons/flags/4x3/aq.svg
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-aq" viewBox="0 0 640 480">
|
||||||
|
<path fill="#3a7dce" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M157.7 230.8c-3.5-7.8-3.5-7.8-3.5-15.6-1.8 0-2 .3-3 0-1.1-.3-1.5 7.2-4.8 5.8-.5-.8 2.4-6.2-.7-8.5-1-.7.2-5.2-.2-7.2 0 0-4 2.4-7-5.8-1.5-2.2-3.5 2-3.5 2s.9 2.4-.7 3c-2.2-1.8-3.9-.8-6.7-3.4-2.8-2.5.6-5.4-4.8-7.5 3.5-9.8 3.5-7.9 12.2-11.8-5.2-4-5.2-4-8.7-9.8-5.2-2-7-4-12.2-7.8-7-9.9-10.5-29.5-10.5-43.2 4.4-4.6 10.5 15.7 19.2 21.6l12.2 5.9c7 3.9 8.7 7.8 14 11.7l15.6 6c7 5.8 10.5 13.6 15.7 15.6 5.7 0 6.8-3.7 8.6-3.9 10.3-.6 15.5-2 17.5-5.5 2.1-2.8 7 1.6 21-4.3l-1.7-7.9s3.7-3.4 8.7-2c-.1-3.5-.5-13 4.5-17.4-3-3.5 1.8-9 2-10.7-1.4-8.6 1.4-8.7 2-11.3.6-2.5-2.4-1.7-1.6-5.2.9-3.5 6-4.3 6.6-7.2.7-2.9-1.1-14.3-1.3-16.8 9.4-2.8 12.4-11.4 15.7-7.8C264 70 265.8 66 276.3 66c1.4-3.6-3.9-6.7-1.8-7.9 3.5-.5 6.1-.2 10.2 5.7 1.3 2 1.6-2.7 2.9-3.2 1.3-.5 4.4-.5 4.9-2.8.5-2.4 1.2-5.6 3-9.5 1.4-3.2 2.5 1.3 3.8 7.5 7.4.3 24 2.1 31 4.3 5.2 1.5 8.7-1.5 13.7-2.2 3.7 4.2 7.2 1 9.2 10 2.7 4.8 7.3.4 8.3 1.8 5.8 18.1 25.8 5.9 27.4 6.2 2.5 0 5.6 8 7.7 7.9 3.2-.6 2.3-3.1 5.2-2.1-.8 6.8 5.6 14.6 5.6 19.7 0 0 1.5.9 3-.6 1.4-1.6 2.7-5.4 4-5.3 3 .5 22 6 25.8 7.9 1.7 3.5 3.3 5.3 6.8 4.7 2.8 2.1.8 5 2.4 5.1 3.5-2 4.7-4 8.2-2.1 3.5 2 7 5.9 8.7 9.8 0 2-1.8 9.8 0 21.6.9 3.9 9.7 32.3 9.7 35.2 0 4-2.7 6-4.5 9.9 7 5.9 0 15.7-3.5 21.6 26.2 5.9 14 17.6 34.9 11.7-5.2 13.8-3.4 12.7 1.8 26.4-10.4 7.8-.2 10.2-7.1 20-.5.7 4.1 8.6 10.5 8.6-1.7 15.6-7 9.8-5.2 33.3-13.7-.3-8.2 17.6-17.4 15.7.5 11.2 5.2 12.2 3.4 23.5-7 2-7 2-10.4 7.9l-5.2-2c-1.8 9.8-5.3 11.8 0 21.6 0 0-6.8.2-8.8 0-.1 3.4 3 4.3 3.5 7.8-.2 1.4-9.9 7.6-17.4 7.9-2 4.8 5.2 10 4.8 12.4-8.2 1.8-11.8 13-11.8 13s4.2 2 3.5 4c-2.2-1.8-3.5-2-7-2-1.7.5-6 0-10 7.7-4.5 1.6-6.6 1-10 6-1.5-4.7-3.7.1-6.3 2-2.7 1.8-6.2 6.5-6.7 6.3.1-1.4 1.6-6.3 1.6-6.3L399 437c-.7.1-.5-5.7-2.2-5.5-1.7.2-6.4 7.3-8 7.5-1.6.2-2.1-2.2-3.5-2-1.4.2-4 7.5-5 7.7-1 .1-5-4.5-8.3-3.8-17.1 6.8-19.9-13.4-22.5-2-3.6-2.2-3-1-6.7.1-2.3.7-2.5-3.4-4.6-3.4-4.1.2-4 4.6-6.2 3.3-1.8-9.2-13-7.6-14-11.5-1-4 4.8-4 6.6-6.8 1.4-4-1.5-5.6 4.3-9.4 7.5-5.7 6.8-19.8 4.9-25.3 0 0-5.9-17.7-7-17.7-3.5-1-3.5 6.5-8.6 8.6-10.5 4-29-9.9-32.2-9.9-2.9 0-16.5 3.6-16-4-2 7.4-9.5 1.7-10 1.7-7 0-4.3 6.1-9 5.9-2.1-.8-23.6-2.3-23.6-2.3v4l-26.1-11.8c-10.5-4-5.3-13.7-22.7-7.8v-11.8h-8.7c3.5-23.6 0-11.8-1.8-33.4l-7 2c-7-10.6 9.8-8.6-5.2-15.7 0 0 .3-11.7-3.5-7.8-.7.5 1.8 5.8 1.8 5.8-14-2-17.4-5.8-17.4-21.5 0 0 11.4 1.8 10.4 0-1.6-3-3.7-22-3.4-23.4-.1-2.6 10.7-9 8.6-15.2 1.4-.6 5.3-.7 5.3-.7"/>
|
||||||
|
<path fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="2.5" d="M595.5 297.6c-.6 1.3-.5 2.6.1 3.6 1.1-1.7.2-2.4 0-3.6zm-476-149.4s-3-.4-2.4 2.3c1-2 2.3-2.2 2.4-2.3zm-.3-6.4c-1.7 0-3.8-.2-3 2.5 1-2.1 3-2.4 3-2.5zm12.7 36.3s2.6-.2 2 2.5c-1-2-2-2.4-2-2.5z" transform="scale(.86021 .96774)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
32
public/vendor/flag-icons/flags/4x3/ar.svg
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 640 480">
|
||||||
|
<path fill="#74acdf" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<g id="ar-c" transform="translate(-64)scale(.96)">
|
||||||
|
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23-.8 8.5 3.8 13.2 5 16.5 1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8-.7-4.6-4.2-6-3.4-16.3.8-10.3-4.2-12.7-3-22"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||||
|
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 320 240)"/>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 320 240)"/>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 320 240)"/>
|
||||||
|
<circle cx="320" cy="240" r="26.7" fill="#f6b40e" stroke="#85340a" stroke-width="1.4"/>
|
||||||
|
<path id="ar-h" fill="#843511" stroke-width="1" d="M329 234.3c-1.7 0-3.5.8-4.5 2.4 2 1.9 6.6 2 9.7-.2a7 7 0 0 0-5.1-2.2zm0 .4c1.8 0 3.5.8 3.7 1.6-2 2.3-5.3 2-7.4.4 1-1.4 2.4-2 3.8-2z"/>
|
||||||
|
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(18.1)"/>
|
||||||
|
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<path fill="#85340a" d="M316 243.7a1.8 1.8 0 1 0 1.8 2.9 4 4 0 0 0 2.2.6h.2c.6 0 1.6-.1 2.3-.6.3.5.9.7 1.5.7a1.8 1.8 0 0 0 .3-3.6c.5.2.8.6.8 1.2a1.2 1.2 0 0 1-2.4 0 3 3 0 0 1-2.6 1.7 3 3 0 0 1-2.5-1.7c0 .7-.6 1.2-1.3 1.2-.6 0-1.2-.6-1.2-1.2s.3-1 .8-1.2zm2 5.4c-2.1 0-3 2-4.8 3.1 1-.4 1.8-1.2 3.3-2 1.4-.8 2.6.2 3.5.2.8 0 2-1 3.5-.2 1.4.8 2.3 1.6 3.3 2-1.9-1.2-2.7-3-4.8-3-.4 0-1.2.2-2 .6z"/>
|
||||||
|
<path fill="#85340a" d="M317.2 251.6c-.8 0-1.8.2-3.4.6 3.7-.8 4.5.5 6.2.5 1.6 0 2.5-1.3 6.1-.5-4-1.2-4.9-.4-6.1-.4-.8 0-1.4-.3-2.8-.2"/>
|
||||||
|
<path fill="#85340a" d="M314 252.2h-.8c4.3.5 2.3 3 6.8 3s2.5-2.5 6.8-3c-4.5-.4-3.1 2.3-6.8 2.3-3.5 0-2.4-2.3-6-2.3"/>
|
||||||
|
<path fill="#85340a" d="M323.7 258.9a3.7 3.7 0 0 0-7.4 0 3.8 3.8 0 0 1 7.4 0"/>
|
||||||
|
<path id="ar-e" fill="#85340a" stroke-width="1" d="M303.4 234.3c4.7-4.1 10.7-4.8 14-1.7a8 8 0 0 1 1.5 3.4c.4 2.4-.3 4.9-2.1 7.5l.8.4c1.6-3.1 2.2-6.3 1.6-9.4l-.6-2.3c-4.5-3.7-10.7-4-15.2 2z"/>
|
||||||
|
<path id="ar-d" fill="#85340a" stroke-width="1" d="M310.8 233c2.7 0 3.3.6 4.5 1.7 1.2 1 1.9.8 2 1 .3.2 0 .8-.3.6-.5-.2-1.3-.6-2.5-1.6s-2.5-1-3.7-1c-3.7 0-5.7 3-6.1 2.8-.5-.2 2-3.5 6.1-3.5"/>
|
||||||
|
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-18.4)"/>
|
||||||
|
<circle id="ar-f" cx="310.9" cy="236.3" r="1.8" fill="#85340a" stroke-width="1"/>
|
||||||
|
<path id="ar-g" fill="#85340a" stroke-width="1" d="M305.9 237.5c3.5 2.7 7 2.5 9 1.3 2-1.3 2-1.7 1.6-1.7-.4 0-.8.4-2.4 1.3-1.7.8-4.1.8-8.2-.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
109
public/vendor/flag-icons/flags/4x3/arab.svg
vendored
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" id="flag-icons-arab" viewBox="0 0 640 480">
|
||||||
|
<path fill="#006233" d="M0 0v480h640V0Z" class="arab-fil0 arab-str0"/>
|
||||||
|
<g fill="#fff" fill-rule="evenodd" stroke="#fff">
|
||||||
|
<path stroke-width=".4" d="M1071.9 2779.7c-25.9 38.9-7.2 64.2 19.5 66 17.6 1.3 54.2-24.9 54.1-55.7l-10-5.6c5.6 15.8-.2 20.8-12.1 31.6-23.5 21.3-71.5 22.8-51.5-36.3z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="M1277.2 2881.7c145.8 4.1 192.2-137 102.2-257.8l-8.9 13.3c5.8 56.3 14.2 111.8 15 169.5-17.6 20.7-43.2 13-48.3-10 .3-31.2-9.9-57.6-22.8-82.8l-7.2 13.3c8.4 20.7 17.5 44 19.4 69.5-41.6 49.9-87.6 60-70.5-5.6-32.9 57.5 16.9 98 73.3 9.5 12.1 60.4 58.9 22.9 61.7 9.9 5.1-39.6 2.5-103.4-7.8-153.8 40.6 70.3 42 121 20.4 154.9-24 37.7-76.2 55.3-126.5 70.1z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="M1359.9 2722.2c-31.2 2.3-47.2-4.1-30.3-27.2 16.7-22.6 32.3-4.6 36.5 25.6 3.9 28.3-54.8 64.4-75.1 64.4-30.7 0-44.9-39.5-16.6-75-36.4 103.6 78.6 43.5 85.5 12.2zm-21.6-24c-3.8-.2-6.6 6.5-4.7 7.8 5.5 3.8 14.2 1.5 15.1-.4 1.9-4.2-5.1-7.2-10.4-7.4z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="M1190.5 2771.1c-30 59-.1 83.4 38.4 76.6 22.4-4.1 50.8-20 67.2-41.7.3-47.8-.4-95.2-4.6-141.5 15-17.9-1.3-17.8-7-37-2.6 11.2-8.9 23.3-2.8 32 4.3 46.7 6.7 94 6.6 142.2-30.2 24.3-52.9 33.3-69.1 33.1-33.5-.3-40.7-28.5-28.7-63.7z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="M1251.8 2786.7c-.5-44.5-1.2-95-5.2-126.1 15.6-17.3-.8-17.7-5.9-37.1-3 11-9.6 23-3.8 31.9 2.6 47.6 5.1 95.2 5.6 142.8 3.6-2.3 7.7-3.2 9.3-11.5z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path stroke-width=".4" d="M1135.4 2784.6c-3.8-4.8-6.5-10.2-9.6-14.9-.5-6.7 4-12.9 4.6-16.3 5.1 7.9 8.1 13.9 12.2 17.8m5.4 3.1c7.5 3 16.7 3 25.2 3.2 32.8.6 67.3-4.8 63.6 39.6a66.2 66.2 0 0 1-65.2 61.9c-41.7-.4-77.3-46.4-13-131.1 6.2-1 14.3.7 21 1.3 11.5.9 23.3-.2 36.8-11-1.6-27.9-1.6-54.3-5-79.5-5.8-8.9.8-20.8 3.8-31.9 5.1 19.4 21.4 19.8 5.9 37.2 3.7 28 4.1 56.5 4.1 73.5-7.8 11.9-13.9 24.5-36.7 29.3-23.3-3.4-33.8-36-58.1-25.2 6.7-29.4 68.4-36.1 74.6-12.9-4.1 24.2-61.7 14.5-77 92.7-4.7 24.1 20.7 46.3 46.8 44.5 25.5-1.7 52.7-19.4 55.4-49.2 2.1-24.9-33-22-47.7-21.7-21.4.5-34.9-2.8-43-7.5m21.9-53.9c3.8-3.6 17.1-6.1 21.9-.3-3.6 2.4-7.1 5-10 8.1-5-2.6-8.3-5.2-11.9-7.8z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="M1194 2650.9a49 49 0 0 1 5.3 21c-2.2 10.4-11.1 20.1-20.3 20.4-5.7.2-12.1-1.4-16.6-10.3-.5-1.1-2.9-3.7-5.2-2.5-10.1 16.6-17.6 23.6-26.7 23.5-18.2-.3-12.8-16.5-29.6-21.5-7-.2-18.5 6.9-24.4 20.8-22.4 63.5-42.8-.2-34.1-29.8 1.3 28.3 8.1 45.1 15.1 44.6 5.1-.5 9.6-12.3 16.1-24.7 5-9.5 17-26.6 29.7-26.6 11.6.3 4.3 21.6 27.5 21.3 11.2-.2 21.5-8.8 31.9-26 2.3-.4 2.9 3.7 3.4 5.1 1.6 5.9 11.8 22.1 25.6 7.3-.7-3.2-.4-8.5-3.9-9.6z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path stroke-width=".4" d="M1266.9 2598.3c-12.3 6.1-21.3.5-26.4-4.9 8.9-1.8 15.8-5 17.8-12-4-9-13.5-12.9-26.9-13-17.9.5-27.1 7.7-28.2 17.6 8.3.3 15.8-2 19 6-14.7 7.2-32 9.8-50.8 9.7-30.8 1.6-35.3-12.3-43.4-24.5-.6-.8-3.3-2.1-4.7-1.9-9.5 0-16.5 33.2-27.2 33.1-10.7-1.4-8.3-21.4-11.4-32.8-2.6 17.9 3.3 84.5 36.4 12.2 1-2.4 2.4-1.7 3.3.3 8.9 20.2 27 27.2 46.5 28.2 16.3.9 37.1-6.2 59.4-18.8 5.9 6.5 10.6 13.9 23 15.3 14.5.7 30-9.8 33.5-22.8 1.8-6.7 2.1-19.9-5-20.1-9.9-.3-17.1 23.7-14.8 45.3.2-.3 1.3-5.4 1.3-5.4m-43.8-28.8c6.5-3 12.8-4.4 17.8 2.2a27.4 27.4 0 0 0-8.4 4c-2.8-2.2-6.6-3.3-9.4-6.2zm47.8 14.9c1.6-7.1 2.5-12.8 8.3-16.5 1.2 7.5 1.4 11.7-8.3 16.5zm39 11c-1.9-6.1-3.8-11.4-4.4-18-1.4-13.4 10.1-21 20.5-19.9 10.7 1.1 17.8 5.1 28 8.6 8 2.7 18.8 4.8 29.1 7.7 5.8 2.6 0 9.4-1.5 10.3-25.8 10.1-44.1 26.1-60.5 26.8-9.8.5-18.5-5.9-26.4-19-.5-25.4-1.4-55.2-3.9-73.9 3.8-3.8 4.6-6.6 6.4-9.7 2 24.7 2.8 50.7 3.3 76.9 2.1 4.5 4.7 8.3 9.4 10.2zm16.5 2c-13.8 3.9-12.1-7.8-13.4-15-1.5-8.4-.5-17.9 10.2-15.5 13.9 3.7 26.6 8.6 38.9 13.8z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path stroke-width=".4" d="m1314.3 2621.3 1.9 9.3h1.5l-.6-8.7" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m1094.2 2718.5 7-7.2 8.1 6.9-7.5 6.7zm17.8-2.4 7.1-7.2 8.1 6.9-7.5 6.7zm-49.5-74.6 7.1-7.2 8.1 6.9-7.5 6.7zm3.2 21.2 7.1-7.2 8 6.9-7.5 6.7zm128.5 35.5 6.5-5.3 6 6.5-6.8 4.8zm-85.8-135.7 4.6-4.7 5.3 4.5-4.9 4.4zm11.7-1.5 4.6-4.8 5.3 4.6-4.9 4.3zm245.6 53.7-4.4 3.7-4.2-4.3 4.6-3.4z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path stroke-width=".4" d="m1158.7 2747.4-.5 7.9 12.6 1.2 10.1-7.6z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
<path d="m1265.2 2599.8 3.7-.8-.4 10.3-2.3.9z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#fff" d="M320 326.3c51.6 0 93.6-38.2 93.6-85.2a81.9 81.9 0 0 0-32.6-64.4 70.2 70.2 0 0 1 19.2 48c0 40.8-35.9 73.9-80.2 73.9-44.3 0-80.2-33.1-80.2-74 0-18.3 7.2-35.1 19.2-48a81.8 81.8 0 0 0-32.6 64.6c0 46.9 42 85.1 93.6 85.1" class="arab-fil2"/>
|
||||||
|
<g fill="#fff" stroke="#000" stroke-width="8">
|
||||||
|
<path d="M-54 1623c-88 44-198 32-291-28-4-2-6 1-2 12 10 29 18 52-12 95-13 19 2 22 24 20 112-11 222-36 275-57zm-2 52c-35 14-95 31-162 43-27 4-26 21 22 27 49 5 112-30 150-61z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M0 1579c12 0 34-5 56-8 41-7 11 56-56 56v21c68 0 139-74 124-107-21-48-79-7-124-7s-103-41-124 7c-15 33 56 107 124 107v-21c-67 0-97-63-56-56 22 3 44 8 56 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M54 1623c88 44 198 32 291-28 4-2 6 1 2 12-10 29-18 52 12 95 13 19-2 22-24 20-112-11-222-36-275-57zm2 52c35 14 94 31 162 43 27 4 26 21-22 27-49 5-112-30-150-61z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M3 1665c2 17 5 54 28 38 31-21 38-37 38-67 0-19-23-47-69-47s-69 28-69 47c0 30 7 46 38 67 23 16 25-21 28-38 1-6 6-4 6 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" stroke="#000" stroke-width="8">
|
||||||
|
<path d="M-29 384c-13-74-122-79-139-91-20-13-17 0-10 20 20 52 88 73 119 79 25 4 33 6 30-8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M4 386c11-76-97-112-110-129-15-18-17-7-10 14 13 45 60 98 88 112 23 12 30 17 32 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M93 430c10-91-78-105-101-134-15-18-16-8-11 13 10 46 54 100 81 117 21 13 30 18 31 4z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M66 410c-91-59-155-26-181-29-25-3-33 13 10 37 53 29 127 25 156 14 30-12 21-18 15-22zm137 40c-28-98-93-82-112-94s-21-9-17 13c8 39 75 82 108 95 12 4 27 10 21-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M190 467c-78-63-139-16-163-23-18-5-10 7-3 12 50 35 112 54 160 32 19-8 20-10 6-21zm169 64c1-62-127-88-154-126-16-23-30-11-22 26 12 48 100 101 148 111 29 6 28-4 28-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M355 542c-81-73-149-49-174-56-25-6-35 9 4 39 48 36 122 43 153 36s23-14 17-19zm145 107c-23-106-96-128-114-148-17-20-35-14-20 34 18 57 77 107 108 119 30 13 28 3 26-5z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M499 663c-59-95-136-92-160-105-23-14-39-2-8 39 36 50 110 78 144 80s28-7 24-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M575 776c34-108-44-148-52-166-9-18-18-18-23 1-22 77 49 152 60 167 11 14 13 7 15-2z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M559 806c-27-121-98-114-114-131-17-17-19-5-16 17 8 59 79 99 111 119 10 6 22 13 19-5zm68 142c49-114-9-191-27-208-18-16-29-23-23 0 8 35-20 125 23 191 14 22 16 43 27 17z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M601 971c11-70-29-134-72-159-25-15-26-11-26 10 2 65 63 119 81 149 17 28 16 7 17 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M590 1153c-36-132 39-208 62-223 22-16 36-22 26 3-15 37 1 140-56 205-18 22-25 45-32 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M598 1124c30-115-35-180-55-193-19-13-31-18-22 3 12 32-1 122 49 178 16 19 22 38 28 12z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M561 1070c-54 58-55 143-31 193 15 29 17 27 31 6 38-61 15-149 17-188 1-37-11-17-17-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M650 1162c0 80-49 145-101 165-30 11-30 8-26-16 14-90 83-123 108-152 24-28 19-5 19 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M464 1400c88-80 41-136 45-188 2-28-9-21-19-11-56 55-59 153-47 191 5 17 13 15 21 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M582 1348c-29 88-106 142-171 145-38 2-37-1-24-27 49-94 136-105 175-129 36-22 23 2 20 11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M343 1513c114-57 91-152 112-176 15-17-3-15-12-9-67 39-121 101-122 167 0 25 2 28 22 18z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M187 1619c144 23 211-86 253-96 22-5 6-14-5-15-96-11-218 34-255 84-15 20-15 24 7 27z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M333 1448c-29 95-137 173-218 179-38 3-38-1-24-26 65-118 178-138 218-168 34-26 27 6 24 15zM29 384c13-74 122-79 139-91 20-13 17 0 10 20-20 52-88 73-119 79-25 4-33 6-30-8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-4 386c-11-76 97-112 110-129 15-18 17-7 10 14-13 45-60 98-88 112-23 12-30 17-32 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-93 430c-10-91 78-105 101-134 15-18 16-8 11 13-10 46-54 100-81 117-21 13-30 18-31 4z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-66 410c91-59 155-26 181-29 25-3 33 13-10 37-53 29-127 25-156 14-30-12-21-18-15-22zm-137 40c28-98 93-82 112-94s21-9 17 13c-8 39-75 82-108 95-12 4-27 10-21-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-190 467c78-63 139-16 163-23 18-5 10 7 3 12-50 35-112 54-160 32-19-8-20-10-6-21zm-169 64c-1-62 127-88 154-126 16-23 30-11 22 26-12 48-100 101-148 111-29 6-28-4-28-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-355 542c81-73 149-49 174-56 25-6 35 9-4 39-48 36-122 43-153 36s-23-14-17-19zm-145 107c23-106 96-128 114-148 17-20 35-14 20 34-18 57-77 107-108 119-30 13-28 3-26-5z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-499 663c59-95 136-92 160-105 23-14 39-2 8 39-36 50-110 78-144 80s-28-7-24-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-575 776c-34-108 44-148 52-166 9-18 18-18 23 1 22 77-49 152-60 167-11 14-13 7-15-2z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-559 806c27-121 98-114 114-131 17-17 19-5 16 17-8 59-79 99-111 119-10 6-22 13-19-5zm-68 142c-49-114 9-191 27-208 18-16 29-23 23 0-8 35 20 125-23 191-14 22-16 43-27 17z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-601 971c-11-70 29-134 72-159 25-15 26-11 26 10-2 65-63 119-81 149-17 28-16 7-17 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-590 1153c36-132-39-208-62-223-22-16-36-22-26 3 15 37-1 140 56 205 18 22 24 45 32 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-598 1124c-30-115 35-180 55-193 19-13 31-18 22 3-12 32 1 122-49 178-16 19-22 38-28 12z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-561 1070c54 58 55 143 31 193-15 29-17 27-31 6-38-61-15-149-17-188-1-37 11-17 17-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-650 1162c0 80 49 145 101 165 30 11 30 8 26-16-14-90-83-123-108-152-24-28-19-5-19 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-464 1400c-88-80-41-136-45-188-2-28 9-21 19-11 56 55 59 153 47 191-5 17-13 15-21 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-582 1348c29 88 106 142 171 145 38 2 37-1 24-27-49-94-136-105-175-129-36-22-23 2-20 11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-343 1513c-114-57-91-152-112-176-15-17 3-15 12-9 67 39 121 101 122 167 0 25-2 28-22 18z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-187 1619c-144 23-211-86-253-96-22-5-6-14 5-15 96-11 218 34 255 84 15 20 15 24-7 27z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
<path d="M-333 1448c29 95 137 173 218 179 38 3 38-1 24-26-65-118-178-138-218-168-34-26-27 6-24 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#006233" d="M359.6 128.9c-4.4-3-20.8-1.3-23.9-3.3 5.9 4.5 19 1.3 24 3.3zm39.7 7.6c-3.5-5.7-24.4-9.6-27.5-14.7 5.5 9.8 21.6 8.5 27.5 14.7m-3 6.6c-7.8-6.8-25.8-4-31.3-8 12.7 10.4 19.7 2.3 31.2 8zM351 112.8c4.9 2.4 11 4.7 14 10.3-3.5-4.3-9.8-6-15-9.6.3 0 .7-.4 1-.7m77 44c-3.1-6.4-14-13.4-14.9-15.8 3 8.3 12 10.3 14.8 15.8zm2.7 11.3c-9.4-13.4-24.1-12-30-17 4.5 4.9 21.4 8 30 17m21.8 20.7c.7-14.3-11-19.6-11.4-27.7-.3 9.6 12 22.6 11.4 27.7m-5.8 7.7c-2.4-12.4-18.3-13.2-21.1-20.5 0 6.8 18.7 13.9 21 20.5zm13.1-7c8.5 9.4 2.6 23.7 6.1 34.1-4.2-7.7-2.1-26.9-6-34.1zm-13.8 40c12.6 12.5 7.5 26.3 12.6 32.3-6.3-8.3-5.4-24.5-12.6-32.2zm26.3 1.8c-10.9 10.9-4.3 27.3-10 35 6.4-6.6 5.5-27 10-35m-13.7 0c-1.4-12.6-14.3-19.2-15.4-26-1.5 6.8 12.4 17.5 15.4 26m-6.5 30c2 8.8-5.7 27.6-3.3 33.4-5.2-10 4.4-29 3.3-33.3zm16.6 20.1c-5.1 15.6-15.5 14.6-18.7 24 2.3-9 16-17.1 18.7-24m-33.5 7.3c-6.8 10.5-1.2 22.4-6.8 29.9 8-7.5 3.7-21.4 6.8-29.9m16.4 28.6c-8.2 13.9-25.1 12.6-31.9 22.6 6.8-12.6 27.7-14.7 32-22.6zm-29.8-1.7c-14.5 9.2-10 18.8-21.1 29 13.8-10.2 12.7-21.5 21.1-29m-6.8 37.2c-14-.5-34.2 16.2-46.4 14.9 12.2 2.4 34.7-12.6 46.4-15zm-22.7-15c-1 13-37.6 21.4-41.5 30.1 4.4-11.5 36.6-20 41.5-30zm-82.8-240c-4.7-3.7-10.4-6.7-12-10.3 1.2 4.7 5.8 8 10.5 11.3.5-.2 1-.9 1.5-1.1zm-8 3.7c-7.3-3.2-15.7-3-19.5-7.4 2.4 4.4 10.3 6.1 17.1 8.5.7-.4 1.7-.9 2.4-1zm-21.1 27.3c4.4-3 20.8-1.2 23.9-3.2-5.9 4.5-19 1.3-24 3.2zm-39.7 7.7c3.5-5.7 24.4-9.6 27.5-14.7-5.4 9.8-21.6 8.5-27.5 14.7m3 6.6c7.8-6.8 25.9-4 31.3-8-12.7 10.4-19.7 2.3-31.2 8zm31.3-20c4.4-8.6 17-9.6 20.4-14.8-5 7.7-15.7 9-20.4 14.8m36-7.5c13-5.5 25.7-.8 31.8-3.4-7.5 3.6-25.4 1.9-31.7 3.4zm-98.9 41.2c3-6.4 13.8-13.5 14.8-15.8-3 8.3-12 10.3-14.8 15.8m-2.8 11.3c9.4-13.4 24.1-12 30-17-4.4 4.9-21.3 8-30 17m-21.8 20.7c-.7-14.3 11-19.6 11.5-27.7.2 9.6-12 22.6-11.5 27.7m5.8 7.7c2.4-12.4 18.3-13.2 21.1-20.5 0 6.8-18.7 13.9-21 20.5zm-13.1-7c-8.4 9.4-2.6 23.6-6 34.1 4.1-7.7 2-26.9 6-34.1m13.8 40c-12.6 12.5-7.5 26.3-12.6 32.3 6.3-8.3 5.4-24.5 12.6-32.2zm-26.2 1.8c10.8 10.9 4.2 27.3 9.8 35-6.3-6.6-5.4-27-9.8-35m13.6 0c1.4-12.6 14.3-19.2 15.4-26 1.5 6.8-12.4 17.5-15.4 26m6.5 30c-2 8.8 5.7 27.6 3.3 33.4 5.2-10-4.4-29-3.3-33.3zm-16.6 20.1c5.2 15.6 15.5 14.6 18.8 24-2.4-9-16-17.1-18.8-24m33.5 7.3c6.8 10.5 1.2 22.4 6.8 29.9-8-7.5-3.7-21.4-6.8-29.9m-16.4 28.6c8.2 13.9 25.1 12.6 32 22.6-6.9-12.6-27.8-14.7-32-22.6m29.8-1.7c14.5 9.2 10.1 18.8 21.1 29-13.8-10.2-12.6-21.5-21.1-29m6.8 37.1c14-.4 34.3 16.3 46.4 15-12.1 2.3-34.7-12.6-46.4-15m22.8-15c.9 13.1 37.5 21.4 41.5 30.2-4.5-11.5-36.6-20-41.6-30.1zM301 116c2.8-11.5 17-13.6 18.8-20.5-.7 7.3-17.4 15.4-18.8 20.5m41.5-28.6c-2 8.8-17.3 13.7-19.4 20.3.7-9 16.4-14 19.4-20.3m-12 20.8c7.3-10.7 22.3-8 27.5-14.1-3.8 7.2-22.3 7.4-27.5 14z" class="arab-fil0"/>
|
||||||
|
<path fill="none" stroke="#f7c608" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M429.8 240c0 55.5-49.3 100.4-110.3 100.4-60.9 0-110.3-44.9-110.3-100.3 0-55.5 49.4-100.4 110.3-100.4 61 0 110.3 45 110.3 100.4z"/>
|
||||||
|
<path fill="#f7c608" d="m298 340.5-.5 1.2c-.3.8-1.1 1.3-2.1 1.2l-8-1.9 2.6-7.7 8 1.7c.9.2 1.4 1 1 1.8l-.2 1m-19-4.8.4-1.2c.2-.9 1.1-1.3 2-1a95 95 0 0 0 7.8 2.5l-2.5 7.7c-2.8-.7-5.4-1.4-7.9-2.3-.8-.4-1-2-.7-2.9"/>
|
||||||
|
<path fill="#006233" d="m296.4 339.8-.3.9c-.2.6-1 .9-1.7.8l-6.6-1.6 1.8-5.6c2.4.7 4.9 1.2 6.6 1.5.8.2 1.2.8 1 1.4l-.2.7m-15.8-4 .3-1c.2-.5.9-.8 1.6-.6 1.9.6 4 1.4 6.5 2l-1.8 5.6a98.9 98.9 0 0 1-6.5-1.9c-.7-.4-1-1.5-.7-2.1"/>
|
||||||
|
<path fill="#f7c608" d="m267.7 330.8-.7 1c-.5.8-1.5 1-2.4.7-2-1.2-4.7-2.5-7-3.9l4.8-6.8a80.3 80.3 0 0 0 7.1 3.7c.8.4 1 1.3.5 2l-.6 1m-16.7-9.6.7-1c.5-.8 1.5-1 2.3-.5 1.8 1.3 4.1 2.9 6.7 4.4l-4.9 6.8a91.1 91.1 0 0 1-6.7-4.2c-.7-.7-.4-2.3 0-3"/>
|
||||||
|
<path fill="#006233" d="m266.5 329.7-.6.8c-.3.5-1.1.6-1.9.3-1.6-1-3.8-2-5.8-3.2l3.5-4.9c2 1.3 4.3 2.4 5.9 3.1.6.4.9 1 .5 1.6l-.5.6m-13.8-7.9.5-.8c.4-.5 1.1-.6 1.8-.2a89.5 89.5 0 0 0 5.6 3.6l-3.5 4.9c-2-1.2-4-2.3-5.6-3.5-.6-.5-.5-1.7-.1-2.2"/>
|
||||||
|
<path fill="#f7c608" d="m241.8 313.7-1 .8c-.8.6-1.8.5-2.6 0-1.5-1.6-3.7-3.5-5.5-5.5l6.7-5.3c2 2.1 4.2 4 5.7 5.4.7.6.6 1.5-.1 2l-.9.8m-13-13.4 1-.9c.7-.5 1.7-.5 2.3.2a73 73 0 0 0 5 6l-6.7 5.2c-1.9-2-3.7-3.9-5.2-5.8-.5-.8.3-2.2 1-2.8"/>
|
||||||
|
<path fill="#006233" d="m240.9 312.4-.8.6c-.5.4-1.2.3-1.9-.2l-4.6-4.6 4.9-3.8a77 77 0 0 0 4.7 4.5c.5.6.5 1.3 0 1.7l-.7.5m-10.8-11.2.7-.6c.6-.4 1.3-.3 1.8.2 1.2 1.5 2.6 3.2 4.3 5l-4.9 3.7-4.3-4.8c-.4-.6.1-1.7.6-2.1"/>
|
||||||
|
<path fill="#f7c608" d="m222.2 290.7-1.3.5c-.8.4-1.8 0-2.4-.6l-3.6-6.8 8.1-3.3c1.3 2.5 2.7 5 3.8 6.6.4.8 0 1.6-.8 2l-1 .4m-8.4-16.2 1.2-.6c.9-.3 1.8 0 2.2.8a70.6 70.6 0 0 0 3 7l-8 3.3a60.2 60.2 0 0 1-3.3-6.8c-.2-1 1-2.1 1.9-2.5"/>
|
||||||
|
<path fill="#006233" d="m221.7 289.2-.9.3c-.6.3-1.3 0-1.8-.6l-3-5.6 5.8-2.4a67.8 67.8 0 0 0 3.2 5.5c.3.7.1 1.4-.5 1.6l-.8.3m-7-13.5 1-.3c.6-.3 1.3 0 1.6.6a77 77 0 0 0 2.5 5.8l-5.7 2.4a58 58 0 0 1-2.7-5.7c-.2-.7.6-1.6 1.2-1.9"/>
|
||||||
|
<path fill="#f7c608" d="m210.5 263.5-1.4.2a2 2 0 0 1-2-1.2l-1.5-7.4 8.8-1.1a63.7 63.7 0 0 0 1.7 7.3c.1.9-.5 1.6-1.4 1.7l-1.2.2m-3-17.7 1.4-.2c.9-.2 1.7.4 1.8 1.2.1 2.2.3 4.7.7 7.5l-8.8 1.1a75 75 0 0 1-1-7.4c.2-.9 1.7-1.7 2.6-1.8"/>
|
||||||
|
<path fill="#006233" d="m210.5 262-1 .1c-.6.1-1.2-.3-1.5-1l-1.1-6.2 6.3-.8a64.4 64.4 0 0 0 1.3 6.1c.1.7-.3 1.3-1 1.4l-.8.1m-2.5-14.7 1-.2c.7 0 1.3.4 1.3 1.1.2 1.8.3 4 .7 6.2l-6.3.8c-.4-2-.7-4.2-.8-6.1 0-.7 1.1-1.4 1.8-1.5"/>
|
||||||
|
<path fill="#f7c608" d="m207.7 234.5-1.4-.2c-1 0-1.5-.8-1.6-1.7.3-2 .5-4.8 1-7.4l8.7 1.2a64.7 64.7 0 0 0-.7 7.4c-.1.9-.9 1.4-1.8 1.3l-1.2-.2m2.6-17.7 1.4.1c.9.2 1.5.9 1.4 1.7a68.7 68.7 0 0 0-1.7 7.4l-8.8-1.2c.4-2.5.8-5 1.4-7.4.4-.8 2.1-1.2 3-1"/>
|
||||||
|
<path fill="#006233" d="M208.2 233h-1c-.7-.2-1-.8-1.1-1.5l.8-6.1 6.3.8a65 65 0 0 0-.6 6.2c-.1.7-.7 1.1-1.4 1h-.8m2.1-14.9 1 .2c.7 0 1.1.7 1 1.4-.4 1.7-1 3.8-1.3 6l-6.3-.7 1.1-6.2c.3-.7 1.5-1 2.2-1"/>
|
||||||
|
<path fill="#f7c608" d="m214 206-1.3-.6c-.9-.3-1.2-1.1-1-2 1-2 2-4.6 3.2-6.9l8 3.4a69.8 69.8 0 0 0-3 7c-.3.7-1.2 1-2 .7l-1.2-.5m8-16.4 1.3.6c.8.3 1.2 1.2.8 2a72.5 72.5 0 0 0-3.8 6.6l-8.1-3.4c1.1-2.3 2.3-4.7 3.6-6.7.6-.7 2.4-.7 3.2-.3"/>
|
||||||
|
<path fill="#006233" d="m215 204.7-1-.4c-.6-.2-.8-1-.6-1.6l2.6-5.7 5.8 2.4a66.3 66.3 0 0 0-2.5 5.8c-.3.6-1 .9-1.6.6l-.8-.3m6.7-13.6.9.4c.6.2.8.9.5 1.6a71.3 71.3 0 0 0-3.2 5.5l-5.7-2.4c1-2 1.9-4 3-5.6.4-.6 1.7-.7 2.3-.4"/>
|
||||||
|
<path fill="#f7c608" d="m228.9 180.2-1.1-.9c-.7-.5-.8-1.4-.4-2.2 1.6-1.6 3.4-3.9 5.2-5.8l6.8 5.3a72 72 0 0 0-5 6 1.7 1.7 0 0 1-2.4 0l-.9-.6m12.8-13.7 1 .8c.8.6.8 1.5.2 2.2a78.4 78.4 0 0 0-5.7 5.3l-6.8-5.3c1.9-2 3.7-3.9 5.6-5.5.8-.5 2.5 0 3.2.5"/>
|
||||||
|
<path fill="#006233" d="m230.2 179.2-.8-.6c-.5-.4-.5-1.1-.1-1.7l4.3-4.9 4.8 3.8a71.3 71.3 0 0 0-4.2 5c-.5.5-1.2.6-1.8.2l-.6-.5m10.6-11.4.8.6c.5.4.5 1.1 0 1.6a80 80 0 0 0-4.8 4.6l-4.8-3.8c1.6-1.7 3-3.3 4.6-4.6.7-.5 2-.2 2.4.2"/>
|
||||||
|
<path fill="#f7c608" d="m251 159.2-.7-1c-.5-.8-.3-1.6.4-2.3 2-1.1 4.4-2.8 6.8-4.2l4.8 6.8a78 78 0 0 0-6.7 4.4 1.7 1.7 0 0 1-2.2-.4l-.7-1m16.5-9.8.7 1c.6.8.3 1.7-.4 2.1-2.2 1-4.6 2.2-7.2 3.7l-4.8-6.8c2.3-1.4 4.7-2.8 7-3.9 1-.2 2.4.7 2.9 1.4"/>
|
||||||
|
<path fill="#006233" d="m252.7 158.6-.6-.7c-.3-.6-.1-1.2.4-1.7 1.7-1 3.7-2.4 5.7-3.5l3.4 4.8a97 97 0 0 0-5.5 3.7c-.7.4-1.4.3-1.8-.3l-.5-.6m13.7-8.2.6.8c.3.5.1 1.2-.5 1.5a83.3 83.3 0 0 0-6 3.1l-3.4-4.8 5.8-3.3c.8-.2 1.9.4 2.3.9"/>
|
||||||
|
<path fill="#f7c608" d="m279 144.9-.5-1.3c-.2-.8.2-1.6 1-2l7.9-2.3 2.5 7.7a82.5 82.5 0 0 0-7.8 2.6c-.9.2-1.7-.2-2-1l-.3-1m18.8-5.4.4 1.3c.3.8-.2 1.6-1 1.8a88.9 88.9 0 0 0-8.1 1.7l-2.5-7.7a85 85 0 0 1 8-2c.9 0 2 1.3 2.3 2"/>
|
||||||
|
<path fill="#006233" d="m280.6 144.7-.3-1c-.1-.5.3-1 1-1.4l6.5-2 1.8 5.6a81.2 81.2 0 0 0-6.5 2c-.7.3-1.4 0-1.6-.6l-.3-.7m15.7-4.4.3.9c.2.6-.2 1.2-1 1.4-1.9.3-4.2.8-6.6 1.4l-1.8-5.5a90 90 0 0 1 6.6-1.6c.8-.1 1.6.8 1.8 1.4"/>
|
||||||
|
<path fill="#f7c608" d="M310 138.2v-1.3c0-.8.8-1.5 1.7-1.7l8.2-.2v8.1a84 84 0 0 0-8.2.4c-1 0-1.6-.6-1.6-1.5v-1m19.7-.2v1.2c0 .9-.7 1.5-1.7 1.5a90 90 0 0 0-8.2-.4V135c2.8 0 5.7 0 8.2.2 1 .2 1.7 1.7 1.7 2.6"/>
|
||||||
|
<path fill="#006233" d="M311.8 138.5v-1c0-.6.5-1 1.3-1.2l6.9-.1v5.8c-2.6 0-5.1.1-6.9.3-.7 0-1.3-.5-1.3-1v-.9m16.3-.1v.9c0 .6-.5 1-1.3 1a82.4 82.4 0 0 0-6.8-.2v-5.8l6.8.1c.8.2 1.3 1.2 1.3 1.9"/>
|
||||||
|
<path fill="#f7c608" d="m340 139.6.3-1.2c.3-.8 1.1-1.2 2.1-1.2l8 1.8-2.5 7.8a84.5 84.5 0 0 0-8-1.6c-.9-.3-1.4-1-1.1-1.9l.3-1m19 4.7-.4 1.2c-.2.9-1.1 1.3-2 1a87.5 87.5 0 0 0-7.8-2.4l2.5-7.8c2.7.7 5.4 1.4 7.8 2.3.8.4 1 2 .8 2.8"/>
|
||||||
|
<path fill="#006233" d="m341.5 140.3.2-.9c.2-.6 1-.9 1.7-.8l6.6 1.5-1.7 5.6a83.5 83.5 0 0 0-6.7-1.4c-.7-.2-1.1-.8-1-1.4l.3-.7m15.8 4-.3.8c-.2.6-.9 1-1.6.7a86.6 86.6 0 0 0-6.5-2l1.7-5.6c2.3.6 4.6 1.2 6.6 1.9.7.3 1 1.5.7 2"/>
|
||||||
|
<path fill="#f7c608" d="m370.2 149.1.7-1c.5-.8 1.5-1 2.4-.7 2 1.1 4.7 2.4 7.1 3.8l-4.7 6.9a80.6 80.6 0 0 0-7.3-3.6c-.7-.5-1-1.4-.5-2.1l.7-1m16.8 9.5-.8 1a1.7 1.7 0 0 1-2.2.5 82.3 82.3 0 0 0-6.7-4.3l4.7-6.9c2.4 1.4 4.8 2.7 6.8 4.2.7.6.4 2.2-.1 3"/>
|
||||||
|
<path fill="#006233" d="m371.5 150.2.5-.8c.4-.5 1.1-.6 1.9-.4 1.6 1 3.8 2 5.8 3.2l-3.4 5a79.3 79.3 0 0 0-6-3.1c-.6-.4-.8-1-.4-1.6l.4-.7m14 7.9-.6.8c-.4.5-1 .6-1.8.2a81.5 81.5 0 0 0-5.6-3.6l3.4-4.9 5.7 3.4c.6.6.5 1.7.1 2.3"/>
|
||||||
|
<path fill="#f7c608" d="m396.3 166 1-.9c.7-.5 1.7-.5 2.5 0l5.6 5.5-6.6 5.3a74.7 74.7 0 0 0-5.8-5.3c-.6-.6-.6-1.5.1-2l.9-.8m13.2 13.3-1 .9a1.7 1.7 0 0 1-2.4-.2 72 72 0 0 0-5-5.9l6.7-5.3c1.8 2 3.7 3.8 5.2 5.7.4.8-.3 2.3-1 2.8"/>
|
||||||
|
<path fill="#006233" d="m397.2 167.3.7-.6c.5-.4 1.3-.3 2 .1 1.2 1.4 3 3 4.6 4.6l-4.8 3.8a73.6 73.6 0 0 0-4.8-4.5c-.5-.5-.5-1.2 0-1.6l.7-.5m11 11-.8.7c-.5.4-1.3.3-1.8-.2a75.1 75.1 0 0 0-4.3-4.9l4.8-3.8 4.4 4.7c.4.7-.1 1.8-.6 2.2"/>
|
||||||
|
<path fill="#f7c608" d="m416.1 188.9 1.3-.6c.8-.3 1.8 0 2.4.7l3.7 6.6-8.1 3.5c-1.3-2.6-2.8-5-4-6.6-.3-.8 0-1.6.9-2l1-.5m8.6 16.2-1.3.5c-.8.4-1.8 0-2.1-.7a70.7 70.7 0 0 0-3.1-7l8-3.4a81.1 81.1 0 0 1 3.3 6.9c.2.9-1 2-1.8 2.4"/>
|
||||||
|
<path fill="#006233" d="m416.6 190.4.9-.4c.6-.3 1.3 0 1.8.6l3 5.5-5.8 2.5a74.4 74.4 0 0 0-3.2-5.5c-.3-.6-.1-1.3.5-1.6l.8-.3m7 13.5-.8.3c-.7.3-1.3 0-1.7-.6-.7-1.7-1.5-3.7-2.6-5.8l5.8-2.5 2.8 5.7c.1.8-.7 1.7-1.3 2"/>
|
||||||
|
<path fill="#f7c608" d="m428 215.9 1.4-.2a2 2 0 0 1 2.1 1.2l1.5 7.3-8.8 1.3a65.4 65.4 0 0 0-1.7-7.3c-.1-.9.4-1.6 1.4-1.7l1.1-.2m3.2 17.7-1.4.2c-.9.1-1.7-.4-1.8-1.3a71 71 0 0 0-.8-7.4l8.8-1.3c.4 2.6.8 5.1 1 7.5 0 .9-1.6 1.7-2.5 1.8"/>
|
||||||
|
<path fill="#006233" d="m428 217.4 1-.1c.7-.1 1.3.4 1.5 1 .3 1.8.9 4 1.2 6.1l-6.3 1a64.5 64.5 0 0 0-1.3-6.2c-.1-.7.2-1.3 1-1.3l.8-.2m2.6 14.7-1 .2c-.7 0-1.3-.4-1.4-1a67.2 67.2 0 0 0-.7-6.3l6.3-.9c.4 2.2.8 4.3.9 6.2 0 .7-1.1 1.4-1.8 1.5"/>
|
||||||
|
<path fill="#f7c608" d="m431.1 244.9 1.4.1c1 .1 1.6.9 1.7 1.8l-.9 7.4-8.8-1.1c.4-2.7.6-5.5.6-7.5.1-.8 1-1.4 1.9-1.2l1.1.1m-2.4 17.8-1.4-.2c-1 0-1.5-.8-1.4-1.7.6-2 1.2-4.6 1.6-7.3l8.8 1c-.4 2.6-.8 5.2-1.3 7.4-.4.9-2.1 1.3-3 1.2"/>
|
||||||
|
<path fill="#006233" d="M430.6 246.4h1c.7.2 1.1.8 1.2 1.5l-.8 6.2-6.3-.8.6-6.2c0-.7.6-1.2 1.3-1.1h.9m-2 14.9-1-.1c-.7-.1-1.1-.7-1-1.4.4-1.8.9-3.8 1.2-6.1l6.3.8a76.8 76.8 0 0 1-1 6c-.3.8-1.6 1.2-2.2 1"/>
|
||||||
|
<path fill="#f7c608" d="m425.1 273.5 1.3.5c.9.4 1.2 1.2 1 2l-3 7-8.2-3.3a66 66 0 0 0 3-7c.3-.8 1.2-1.1 2-.8l1.2.4m-7.9 16.5-1.2-.5c-.9-.4-1.3-1.2-.9-2 1.2-1.8 2.6-4.1 3.8-6.6l8.1 3.3a78.3 78.3 0 0 1-3.5 6.7c-.6.7-2.4.7-3.3.3"/>
|
||||||
|
<path fill="#006233" d="m424.2 274.8 1 .3c.5.3.7 1 .6 1.7l-2.6 5.7-5.9-2.3a66.2 66.2 0 0 0 2.5-5.8c.3-.7 1-1 1.6-.8l.8.4m-6.5 13.6-1-.3c-.6-.3-.8-1-.4-1.6a71.2 71.2 0 0 0 3-5.5l5.9 2.3a80.7 80.7 0 0 1-3 5.6c-.5.6-1.8.7-2.4.4"/>
|
||||||
|
<path fill="#f7c608" d="m410.5 299.4 1.1.8c.7.6.8 1.5.4 2.3-1.6 1.6-3.4 3.8-5.2 5.8L400 303c2-2 3.8-4.3 5-6 .6-.6 1.6-.6 2.3-.1l.9.7m-12.6 13.8-1-.8c-.8-.6-.9-1.5-.3-2.1 1.7-1.5 3.7-3.3 5.7-5.5l6.8 5.3a88.2 88.2 0 0 1-5.5 5.6c-.8.5-2.5 0-3.2-.6"/>
|
||||||
|
<path fill="#006233" d="m409.2 300.4.8.6c.5.4.5 1 .1 1.7l-4.3 4.8-4.9-3.7c1.7-1.8 3.2-3.6 4.2-5 .5-.5 1.3-.6 1.8-.2l.6.5m-10.4 11.5-.8-.6c-.5-.4-.5-1.1 0-1.7a77 77 0 0 0 4.6-4.5l5 3.7c-1.6 1.7-3.1 3.3-4.7 4.7-.6.4-1.8.1-2.4-.3"/>
|
||||||
|
<path fill="#f7c608" d="m388.5 320.5.7 1c.5.8.3 1.7-.3 2.3l-6.7 4.3-5-6.8a77.9 77.9 0 0 0 6.7-4.4 1.7 1.7 0 0 1 2.2.4l.7.9m-16.4 10-.7-1c-.6-.8-.4-1.7.4-2.2a84.3 84.3 0 0 0 7.2-3.7l4.8 6.8-7 4c-.9.2-2.3-.7-2.9-1.4"/>
|
||||||
|
<path fill="#006233" d="m386.9 321.1.5.8c.4.5.2 1.2-.4 1.7l-5.6 3.5-3.5-4.8 5.6-3.7c.6-.4 1.4-.3 1.7.2l.5.7m-13.6 8.3-.6-.8c-.3-.5-.1-1.2.5-1.6a83 83 0 0 0 6-3.1l3.4 4.8c-2 1.2-4 2.4-5.8 3.3-.7.3-1.9-.3-2.2-.8"/>
|
||||||
|
<path fill="#f7c608" d="m360.8 335.1.4 1.2c.3.8-.2 1.7-1 2l-7.8 2.5-2.6-7.8a75.4 75.4 0 0 0 7.7-2.6c.9-.2 1.8.2 2 1l.4 1m-18.8 5.5-.4-1.3c-.3-.8.2-1.6 1-1.8 2.4-.4 5.1-1 8-1.8l2.7 7.8c-2.7.7-5.4 1.5-8 2-1 0-2-1.3-2.3-2"/>
|
||||||
|
<path fill="#006233" d="m359 335.3.4.9c.2.6-.3 1.2-1 1.5l-6.4 2-1.9-5.6a82.2 82.2 0 0 0 6.4-2c.8-.3 1.5 0 1.7.6l.2.7m-15.6 4.5-.3-.9c-.2-.6.2-1.2 1-1.4a82.4 82.4 0 0 0 6.6-1.5l1.9 5.6a99.4 99.4 0 0 1-6.6 1.6c-.8 0-1.7-.8-2-1.4"/>
|
||||||
|
<path fill="#f7c608" d="M329.7 342v1.3c0 .9-.7 1.5-1.6 1.7-2.4 0-5.4.3-8.2.3l-.1-8.1a82.2 82.2 0 0 0 8.2-.5c1 0 1.6.6 1.6 1.5v1m-19.6.4v-1.2c0-.9.6-1.5 1.6-1.5 2.3.1 5.1.3 8.2.3v8.1l-8.2-.1c-.9-.2-1.6-1.7-1.6-2.6"/>
|
||||||
|
<path fill="#006233" d="M328 341.8v.9c0 .6-.6 1-1.4 1.2l-6.8.2v-5.7c2.5 0 5-.3 6.8-.4.8 0 1.3.4 1.4 1v.8m-16.4.3v-1c0-.5.5-1 1.3-1 2 .1 4.3.3 6.9.2v5.8H313c-.8-.2-1.4-1.3-1.4-1.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 26 KiB |
72
public/vendor/flag-icons/flags/4x3/as.svg
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-as" viewBox="0 0 640 480">
|
||||||
|
<path fill="#006" d="M0 0h640v480H0Z"/>
|
||||||
|
<path fill="#bd1021" d="m-.6 240 640-240v480Z"/>
|
||||||
|
<path fill="#fff" d="m59.7 240 580-214.3v428.6"/>
|
||||||
|
<path d="M474 270.4c5.1.3 5 5.4 5 5.4l18 .4c2.3-6.3 4.8-5.6 9.2-2.4a32.9 32.9 0 0 0 8.7 4.2c1.7-9 14.5-7.2 14.5-7.2 5.6-13 6-12.9 2.7-14.5a10.7 10.7 0 0 1-4.6-4.5c-3-3.7-4.6-9.1-5-12.4-.4-3.3-4.2 1.6-5 .6-.6-1-6.3-.4-6.3-.4 1.4 1.5-3.4.6-3.4.6.5.4 0 1.7 0 1.7-.4-.6-4.1-1.2-4.1-1.2a6.1 6.1 0 0 1-1.1 1.6c-2-.8-6-.7-6-.7a20 20 0 0 0-10.9 2.8c-1.6.9-7.4 3.8-12.3 8.5-4.7 4.6-7.4 4-7.4 4-1.4 5.2-12.8 11.5-12.8 11.5-1.8 1.6-7.6 2.4-10.5 0-2.9-2.4 0-6.9 0-6.9 1.2-2 2.2-1.9 2.3-9 .1-4.7 5-8.5 10-14 6.3-6.8 15-18 15-18 0 3.4 1.8 4 1.8 4 1.7-3.5 4.2-6.3 4.2-6.3.1.3.5.4.5.4 2-2.6 3-3.6 3-3.6-.5-.3-6 0-11 4.4s-8.4 3-8.4 3c-3.5-1.2-3.8-4-3.8-4-2.5-10.9 7.4-18.7 7.4-18.7-13.4-3.2-3.7-20.3 13-27.5 16.6-7.2 16.4-10.5 16.4-10.5a13 13 0 0 1 1.8 3c.1 0 1.4-1.9 11-6.1 9.6-4.3 14.2-8 14.2-8 1.2 2.4 1 4 1 4 26.3-9.1 52-30.2 52-30.2.8 1.7.5 4.4.5 4.4 4.2-4 19.7-13.2 19.7-13.2a8.6 8.6 0 0 1-4.6 8.2 7.7 7.7 0 0 1 .8 2.3 360 360 0 0 0 14.4-9.5c4.3 3.7.4 9.8.4 9.8 1.6-.3 2.6-1.6 2.6-1.6 1.2 6.4-5.9 12-5.9 12 1.3 0 3.3-1.3 3.3-1.3-1.3 7-14.4 14.6-14.4 14.6 1.9 1.8 0 4-1.6 5-1.5 1-4.3 3.3-3.4 4.2.8 1 6.7-3.2 6.7-3.2 1 2.9-6.5 8.6-6.5 8.6 5.2.7 19.6-5.9 19.6-5.9-1.1 5.6-6.6 10-13.3 12.5-6.7 2.5-6.4 3-6.4 3 1.2.8 10.5-1.8 10.5-1.8-2.8 6.2-12.5 10.5-12.5 10.5 2.7 2.3 6.3-.4 10-2.9a58.2 58.2 0 0 1 14-6.4c5.3-1.9 9.2-.5 9.2-.5a12 12 0 0 1 8.4.6c8.7.7 9.6 3.9 9.6 3.9 1 .2 1.7.6 4 2.3 2.1 1.6 2 6.6 2 9.2-.2 2.4-.9 2.4-1.3 3-.4.8-.5 1.6-.5 2.5 0 1-2.2 6.9-15.7 6.9h-20.3c-1.2 0-2.5.7-2.5.7-5.7 2.8-2.7-2-9.4 3.6-6.8 5.5-10.2 4.6-10.2 4.6A90.1 90.1 0 0 1 568 221c-4 2.6-3.3 2.3.3 3.8s8.8 0 8.8 0c-3.4 2.3-1 3.4-1 3.4 4.4-2.7 7.2-1.7 7.2-1.7 1.4 3.9-3.8 10-3.8 10 2 .3 5.8 0 5.8 0-1 2.7-4.6 5.6-7.4 6.4-2.7 1-2.3 1.3-1.4 3 .7 1.6.1 3.3.1 3.3-4.8-3.3-5-.4-5-.4-.5 4-.4 9.6-.4 9.6-3.4-1.7-3.5.5-3.5.5-1 3.6-5.1 7.7-5.1 7.7-.2-2.2-2.2-2.8-2.2-2.8-2.2 4.2-6.1 6.7-6.1 6.7-.5 3.5.5 8.6.5 8.6-2.6-.6-3.5-.6-4 0-.3.7.6 1 .6 1l33.4.8c.5 0 2.5.3 2.5 3.8 0 3.7-3 3.9-3 3.9l-36.4-.9s.1 1-1.8 2-1.2-1.1-1.7 3.4c-.6 4.4-7.8-.4-7.8-.4-1.2 1.8-4 4-4 4-1.7-5-3.4-6.4-6-2.2-2.6 4.3 4.8 3.6 4.8 3.6s42.8-6.3 45.1-6.5c2.3-.3 4.9-.1 6 3.1 1 3.3-5.3 3.8-5.3 3.8l-44 4.8c-.9 2.6-4.5 2.4-4.5 2.4.3 2.5-2.3 4-3.6 5-1.4.8-5.6.5-5.6.5-5 3.4-7.6.7-7.6.7-3.3 1.4-5.4.8-8.1-.4-2.8-1.2-2.5-4.5-2.5-4.5l-27.8 3a6.7 6.7 0 0 0-2.2 1.2c1 1.3-2 4.3-2 4.3.9.5 2.5 2.1 2.7 5.5.2 3.7-4.5 4.3-2.2 7 2.3 2.5 6.7.3 11.5-2s9.5-2 11.5-2 7.8 1.6 11.4 2.8c3.6 1.2 4.8.4 5-1.4.2-1.7 1.9-2.3 1.9-2.3-.5 1.8.5 2.6.5 2.6a11 11 0 0 0 3.7-1.3c-.2 1.4-2 2.2-2 2.2-3.4 2.3 1.4 1.5 1.4 1.5a44 44 0 0 1 15.4-1.5 122.5 122.5 0 0 1 14.3 5.2c.4-1.2.1-4 .1-4 3 .8 4.2 2.5 4.2 2.5 1.2-1.2.4-3.4.4-3.4 9.7 5.5-2 8-5.1 9-3 1-3 2.3-3 2.3a28 28 0 0 1 6.4-1.3c2.2-.2 1.4 0 6.5-1 5.2-1 7.8 1.2 7.8 1.2-4.3.2-5.5 1.5-5.5 1.5 2.6 1.7 0 3.4 0 3.4-3.8-5-7.2.1-7.2.1a15.3 15.3 0 0 1 6.4 1.4l5.4 2.7c3.6 1.6 2.9.6 5.6 1.6 2.8 1 1.7 3.7 1.7 3.7a6.8 6.8 0 0 0-3.7-3c-.2 3-3.1 3.5-3.1 3.5 3.6-4-4.1-5.8-7.8-5.7-3.6 0-6.3 2.4-6.3 2.4 7.3 6.9 12.3 4.6 12.3 4.6-.9 2.5-6.9 1.5-6.9 1.5 2.8 2.2 2.5 3.6 2.5 3.6-1.5-1.4-4-.7-9.2-4-5.2-3.5-9.9-2.3-9.9-2.3 5.2 5.3-1.8 8.6-1.8 8.6-2.6 1.6 1 3.5 1 3.5-3.2.6-3.6-2.6-3.6-2.6-1.7-.4-4.2 1.6-4.2 1.6.2-3.2 4.6-1.6 4.6-5 .2-3.5-4-6.2-16.3-4.5-12.4 1.7-16-2.2-16-2.2-1 0-1.2 1-1.2 1 2 2 2.9 2.8 2.6 4.2-.4 1.3.6 1.8.6 1.8-2.3-.2-2.4-2.8-2.4-2.8 0 1.1-.5 1.2-1.3 2.3s0 2.7 0 2.7c-1-.8-2.7-1.8-1-4.3 1.2-1.8-2.7-4.2-2.7-4.2-1.5-1.5-5.6 0-5.6 0a14.9 14.9 0 0 1-13.3-3.7c-1 0-2.9-.6-2.9-.6-8.9 4-16.7-4.6-16.7-4.6-6.7 1.3-9.8-2-11.8-5.2a11.5 11.5 0 0 0-5.2-5c-2.6-1.6-5.2-6.2-2.6-8.7 2-2.1 1.5-2.6 1.5-2.6-3.5-5.9 6.1-7.7 6.3-9.2.3-2 2.3-3.3 4.5-3.4 2.2-.2 2.2 0 3.7-1.4 1.3-1.5 4 .3 4 .3.7-.4 5.5-4.1 9.7-2.2 4.3 1.9 7.9.6 7.9.6 3-.7 28-4 28-4 1.5-2.5 2.7-5.4 9.6-7s12-6 12-6c-1.2-1.2-3.2-1.2-4.2-1.3-1.1 0-3.2-2-3.2-2-1.3.6-2 .3-11 5.8-8.1 5-8.3-4.8-8.3-4.8H479c-.3 3.7-3 5.2-3 5.2l-6.5.3c-3.6-1.8-3.6-8.2-3.6-8.2-19.4.3-30.1 7.2-30.1 7.2-22-11.2-39.2-13.8-39.2-13.8a122.4 122.4 0 0 0 40.8-10.2 63.3 63.3 0 0 0 28.5 9c.5-5.4 4.1-6.7 4.1-6.7z"/>
|
||||||
|
<path fill="#ffc221" d="M442.3 314.6c-5.5 3.2-4.5 5-4 6s.5 2-1 3.6c-1.5 1.5-1.4 2-1.4 2 .3 5.4 4 6.6 5.7 8 1.4 1 3.6 4.5 3.6 4.5 2.9 4.1 5.9 4.2 8.1 4.2 2.3 0 2-.3 1-1.3l-3.4-2.7a17.6 17.6 0 0 1 5.9 4.1c5.6 6.2 10.8 5.4 13.1 5.2 2.3-.3 2-1.7 2-1.7a9 9 0 0 0-2.4-.4c-8.5-.8-11-6.4-11-6.4a24.1 24.1 0 0 0 15.6 6c2.4-.1 2.3.6 1.7.8l-2.4-.2c-1.1 0-1.1.3-.9.8.3.4 1.4.4 2.7.4 1.4 0 .3.1 3.8 2.8 3.6 2.8 12.3.5 12.3.5-5.7-1.3-6.4-4-6.4-4-7.7 1-10.8-3.6-10.8-3.6a32.5 32.5 0 0 0-5.6-3.5 8.9 8.9 0 0 1-5-5.8c1.3 1.8 3.7 3.8 6.7 4.6 3 .8 3.8 1.2 3.8 1.2a3.8 3.8 0 0 1-2.3-.2c-3-1-1.3.3-1.3.3 3.4 2.7 4.3 2.5 4.3 2.5 8.6.9 4.3-2.6 4.3-2.6 6.2 1.5 7.2-.8 7.2-.8 1.3 2.7 6 1.7 6 1.7-6.2 3-1.5 2.1-1.5 2.1 6.3-1.1 7.6.5 7.6.5 1.6 1.5 3.4 1.4 3.4 1.4s1.2 0 3.5.4c2.4.5 6.2 2.5 9.6 2.2 3.5-.5 4 .6 4 .6-.6-.3-2.2-.5-4.8.7-2.7 1.3-7.4 1.6-14.2 0s-7.4-1.3-7.4-1.3a8.7 8.7 0 0 1 3.4 4c.3 1.2 1.5 1.2 1.5 1.2.5-1.5 2.5-2.1 2.5-2.1a27 27 0 0 0 5 2.8c.4-.7 0-1.3 0-1.3 2.6 2.5 5.6 1.7 5.6 1.7.8-.5.6-2 .6-2 1 0 1.2.6 2 1.2.7.4 3 .1 3 .1-.8-.4-1.5-1.7-1.5-1.7 3.5-2.3 11-1.3 11-1.3 5.3 1 4.7 4.5 4.7 4.5a10 10 0 0 1 2.5 2.1c.5-1.2 0-2.5 0-2.5 2.6 1.2 3 4 3 4 3-3.2-2.7-6.8-2.7-6.8 2.7-.4 5.7-.2 7.5 0a13.8 13.8 0 0 1 6.6 3.1c2.1 1.7 5.9 2.5 5.9 2.5-.1-.7-2.2-2-2.7-2.2-.4-.2-.6-.9-.6-.9 1.9.4 3.1.2 3.1.2-6.4-4-8.1-5.9-8.1-5.9 2.4.3 3.8-1.2 3.8-1.2-5.1 0-5.4-1.2-5.4-1.2.7.1 3.1.7 6.2.1s7.2 0 7.2 0c-2.2-3.6-10.7-3-13.5-2.8-2.8.2-3.8-.2-3.8-.2.4-.2.9-.6 3-.7 2.2 0 4.3.2 6.8-1.6 2.3-1.6 5.7-1 5.7-1-.8-1.6-4.7-2.2-8 0-3.5 2.1-6.5 1.5-6.5 1.5 5.3-.8 6.9-2.7 6.9-2.7-1.6-.4-2.5.1-5.8.8-3.2.6-4-.5-4-.5 3.5-2.1 6-3 6-3-3-.6-5.8-2-5.8-2-3.2 3-5.6 4.6-11.7 1.6-6-3.2-9.2-2.8-9.2-2.8a14.2 14.2 0 0 1 14.8.6c4 2.3 5 .4 5 .4-1.2-.7-1-1.5-1-1.5 9.6 4.9 13.8 2 15.9.5 2.1-1.5-1-3.4-1-3.4-.2 3-4 4.6-7.2 3.5-3-1-6-2.4-10.4-4.3-4.4-1.8-10-.8-15.1.2-5.2 1.1-5.9.6-6.4.2-.5-.4-.7-1.7-3.4-.6-2.6 1.1-8.8-1.8-12.6-2.7-3.8-1-10.1-.5-15.5 2.5-5.4 3.1-8.2 2.3-9.8 1.6-1.6-.8-2.7-2.8-.9-4.6s2-2.3 1.8-5c-.2-2.6-2.8-4.2-2.8-4.2 2.4-2.5 3-3 2.2-4-.8-1.2.4-1.2 1.8-1.8 1.4-.6.8-.7.5-1.5-.4-.8-1.2-.6-1.2-.6-3.1.1-4.9-.8-4.9-.8-5.2-2.4-10.1 2.3-10.1 2.3-3-2.3-3.7-.7-4.2-.2-.5.5-1.6 1-3 1-1.2.2-3.1.7-3.8 1.9 0 0-.6 1 .1 2 0 0 .8 1.2-.6 2.7-1.5 1.5-2 1.8-1.5 3.3.4 1.3.3 2.4-.3 3.3 0 0-.7-.7-.5-1.7.2-1 .2-1.6 0-2 0 0-1.5 1.4-1.8 2.4 0 0-.6-1.6 1.6-3.7 2-2 3-3.2 2.4-4-.4-.6-2 .4-2.3.6z"/>
|
||||||
|
<path d="M448.4 338s-2.7-2-2.4-4.9c.3-2.7.3-3 0-3.7 0 0-.5.3-.4 1.4 0 1-.2 2.1-.3 2.3 0 0-1.3-2.3-2-2.8 0 0 .6-2.4-.2-3.4-.7-1.1-1.5-1.2-2.4-.8-1.2.4-2.1 1.5 2 4.8 0 0 1.5 1.3 2.5 3.9 1 2.6 2.8 3 3.1 3.2zm13-7.8s-.1-1.5 1.3-4.3a6.1 6.1 0 0 0 .3-5.6c-.3-.8-.6-.5.9-1.7 1.7-1.5-.7-3.4 2.3-6 0 0 1.8-1.6 2.3-2.3 0 0-3 1.6-5.2 2.5-2 .8-9.6 4.6-8 7.1 1.8 2.5 1.6 2.7 1.3 3.8 0 0-4.6-2.5-3-6.4 0 0 .8-1.5 2.7-3.4 1.8-1.6.8.4 4.3-1.7 0 0 2.7-1.6 4.3-3.9 0 0-2 1.2-2.6 1.4 0 0-4 .8-5.8 2.5-1.6 1.7-5.1 4.7-4 8 0 0-4-.4-5-4.7 0 0-7.6 9.4 8.4 13.8 0 0 3 .8 5.6 1z"/>
|
||||||
|
<path fill="#ffc221" d="M531.6 299c6-1 40.4-6.2 43.6-6.5 3.4-.3 4.7-.8 5.9 2 1.3 3-4.8 3.1-4.8 3.1l-41.1 4.7c-2 .2-2.5-.6-2.5-.6l-1.5-2s-.5-.6.4-.8z"/>
|
||||||
|
<path fill="#5a3719" d="M447.3 317.7s-4.4 9.3 13 11.6c0 0 .1-1 .8-2.5.8-1.5 2.3-4.5.8-6.4-1.4-1.9 1.2-.9 1.5-3.4.5-2.5-.2-2.2 1-3.8 0 0-5.4 2-7.6 4.5-2 2.4 2.9 4.2 0 6.9 0 0-2.5-1-4-3.6 0 0-3.3 0-5.5-3.3"/>
|
||||||
|
<path d="M464.5 329.2s4.3 3.7 9.4 3.6c5.1-.3 7.4-1.6 8.7-3.6 0 0 1 1.5 1 2.6 0 0 4.4-3.7 12-.5s5.4 2.3 7.1 2.5c0 0-3.3-.5-10.7 2.9-7.7 3.5-27.7 2.3-27.6-7.5z"/>
|
||||||
|
<path fill="#5a3719" d="M457.3 312.6s1.9.3 3.8-1.9c0 0-2.6.5-3.8 2zM442.6 330s-3.6-2.8-1.3-3.4c0 0 1.7-.3 1.3 3.4"/>
|
||||||
|
<path d="M521.2 347.8s2-3.5 7.5-3.5 6.1 2.6 13.1 3c0 0-8.4 2.4-14.2.3-3-1.1-5.8-.2-6.4.2"/>
|
||||||
|
<path fill="#5a3719" d="M466.3 331.7s8.4 5 15.7-.5c0 0 .6.6 1.2 2 0 0 5.6-5.4 15.5.4 0 0-1.2-.1-5.9 1.8-6.1 2.7-21.4 4.5-26.5-3.8z"/>
|
||||||
|
<path d="M498.3 336.7s8 1 14.7.6c4.1-.2 8.6-1 6.4.4-2.3 1.3-1.1 1.5 8.4.7 9.4-1-.1 1.7 6.4 2.6 0 0-15.9 8-35.9-4.3"/>
|
||||||
|
<path fill="#5a3719" d="M519.2 331.7s4.6-1.7 9 .3c4.3 2 3.6 2.2 6.5 2.5 0 0-2 2.9-6.7.6-4.8-2.3-6-2.8-8.8-3.4m5.2 14.3s4.6-2.3 9.6 0c.6.4 2 1 3.3 1.2 0 0-3.8 1.3-7.8 0-1.7-.5-3-.9-5.1-1.2m-22.7-8.2s10.3 1 15.8-.1c0 0-6.4 3 9.7 1.7 0 0 3.5-.4 3 .1-.3.5-.6 1 1.2 1.5 0 0-12 5.4-29.7-3.2"/>
|
||||||
|
<path d="M450.7 329.2s.2.7 2.4 1.7a8.7 8.7 0 0 1 4 3.9 5.6 5.6 0 0 0 3.5 2.9s-8 1.7-11.6-2.6c0 0-2.7-3 1.6-6"/>
|
||||||
|
<path fill="#5a3719" d="M513.7 347.6s-3.1-.2-7.5-1.7c-4.3-1.5-5.4-.2-7.9-2-2.4-1.9-7.3-.7-8.2-.6-1 .1-3.6 0-.3-2.1 0 0-2.6 0-3.6-1.4 0 0-1.2 1.2-5.6.8 0 0 2 3-6 2.1a10.3 10.3 0 0 0 11.1 3c0 .2-.5 2.5 3 3.5 3.8.9 4.5 1.6 6.4 2.3 0 0 .3-1.5-4.6-5 0 0 2.6-.2 6.4.7s12.2 3.1 16.8.4m2 3.7s.8 1.8 3.2 1.4a17 17 0 0 1 10.2.8s.7-3.2-7-3.4c0 0-4.8.2-6.4 1.2m-65.2-21s-3 2.5-.3 5c2.4 2.3 6.2 2.1 8 2 0 0-1-.6-2-2-1-1.5-1-2.5-3-3.4-2.1-.9-2.3-1.1-2.7-1.6m-3-12.6s-4.6 9.3 13 11.6c0 0 0-1 .7-2.5.6-1.5 2.1-4.5.8-6.4-1.6-1.9 1.1-.9 1.5-3.4.4-2.5-.3-2.2 1-3.8 0 0-5.5 2-7.7 4.5-2 2.4 2.9 4.2 0 6.9 0 0-2.5-1-4-3.6 0 0-3.3 0-5.5-3.3z"/>
|
||||||
|
<path d="M493.3 339.3s3.7-.6 13 2.9c9.4 3.4 13.3 2.6 14.6 2.5 0 0-5.2 2.8-13.4-.8-7.2-3.2-7.6-2-14.2-4.6"/>
|
||||||
|
<path fill="#ffc221" d="M551.8 337.2s2 0 3.4.5c0 0 .7-.7 2.7-1 0 0-1.3-1.2-6.1.5m-6.4-5.2s2.1 0 2.8-1.2c0 0-1.1-1.3-2.8-2 0 0 .4 1.6 0 3.2m-71.7-23.8s-.5-1 1.8-1.4l31.3-4.5s1.5 0 1.7 1c.3 1.1-.1 1.9-7.2 2.7l-25.6 3.2s-1.9.3-2-1"/>
|
||||||
|
<path fill="#ffc221" d="M502 306.9s0 4.1 4.2 4.7c4 .6 5.5-.2 6.5-2.3.3-.7 1.6-5-.2-5.3-.8-.1-2 0-2.9.3-1.4.7-2.7 1.4-2.3 2 1 1.6 1.2 2 1 2-1.2.3-1.8-.6-2-1.2-.3-.8.5-1.2-2.2-.8-1.2.1-2 .1-2 .6zm17.5-3.2c2 .3 1.9 4.8-.6 6.9-2.8 2.2-5.4 1.3-5.4 1.3-1.4-.5-1.2-.4-.1-2 1-1.5 1.5-3.6.9-5-.2-.5.3-.9 1-1 0 0 2-.4 4.2-.2"/>
|
||||||
|
<path fill="#ffc221" d="M521.3 304.1s1.6 2-.4 5.5c0 0-.8 1 1.1.9 1.8-.2 6.1-2.2 5.7-4.8 0 0-.2-.6-1.3-.6s-.2-.5.3-.8c.4 0 1.9-.6-1.9-3 0 0-.6-.6-1.3-.3-.6.2-2.6 1-2.6 2.2 0 .5.4 1 .4 1z"/>
|
||||||
|
<path fill="#ffc221" d="M525.4 300.9s3 2.1 3 2.8c0 .6-.3 1.5.5 1.3.8 0 4-.7 3-2.8-.8-2.1-1.7-3-3.2-3.4-1.5-.6-1.9.1-3.2 1.1 0 0-.9.6-.1 1m-16.1 3s.5-1.5-2.2-2.2c0 0 1.1-1 3.4-.4 2.2.4 2 2 2 2.1 0 0-1.8 0-3.2.5m5.8-.4s3-.5 4.5-.4c0 0-1.6-3.3-5.7-2.3 0 0 1.5 1.8 1.2 2.7m5.3-.8s0-1.1 2.6-2.1c0 0-1.2-1.2-3-1-2 0-2.5.7-2.5.7s2.3.8 2.9 2.4m1-3.6s1.7.4 2.7 1.3c0 0 1.5-1.7 2.8-2 0 0-2.5-1.4-5.5.7"/>
|
||||||
|
<path fill="#5a3719" d="M435.8 290.9s7.2-6.2 11.2-5.4c4 .8 2 .2 6.4-.5 4.4-.6 9-1.1 10.8-.9 0 0-5.4-3.8-14.9-3.7 0 0-6.6 2.3-11.3 5.3 0 0-8.9-4.9-18-2 0 0 9.9 3.7 15.8 7.2"/>
|
||||||
|
<path fill="#ffc221" d="m512.2 301.4 1.2-.2s2 2.5.6 2.5c-1.2 0-.8-.3-1-1a2.3 2.3 0 0 0-.8-1.3m-9 .2s-.8 1 .6.8c1.7-.3 1.4 0 3.1-1.3 0 0 1.2-1.1 3.2-.4 0 0 1.8.6 3.2-.1 1.4-.8 1.7-.7 2.5-.6.8 0 .8.2 1.7-.6 1-.7 2.8-.1 3.9-1 1.1-1 2.5-.2 0-2 0 0-.5-.5-.5-1 0 0 1 .4 1.8 1 .8.8 2 .5 2.2.4 0 0 .2-2.3 2.3-4.3 2.3-2 2.3-2.2 1-2.2s-3.5-.6-4.3 0-7.2 4.8-11 5.5c-3.8.7-7.3 1.8-9.7 5.8m-101.3-23.4s11.7 3 14.3 4.2c0 0 .6-1.9-4.7-3.4 0 0 12.9-.4 26.4 5.8 0 0 6.6-5.6 27.7-3.9 0 0 0-1.8.2-3.3 0 0-14.8-.4-28.4-8.7 0 0-12.3 6-35.5 9.3m64.7 5.6c-.7-11.8 3.8-13 3.8-13s2.1 0 4.4.5c0 0-3.6 4.3-2.6 12.8 0 0 .4 1.3-2.7 1.3s-2.9-1.5-2.9-1.5z"/>
|
||||||
|
<path fill="#5a3719" d="M469.8 291.7s-2.3-2.3-2.5-4.9c0 0 0-.6 2.2-.6 2.3 0 2.5-.2 3 1.1.6 1.3 2 4 2.3 4.3z"/>
|
||||||
|
<path fill="#ffc221" d="M474.5 285.7a28.3 28.3 0 0 1-.2-4.5c.1-6.6 1.2-6 1.7-5.2h2.3s-1.7-7.4-3.7-3a19 19 0 0 0-1.5 10.4c.1 2 .3 3.3.6 4z"/>
|
||||||
|
<path fill="#5a3719" d="M500.2 285.7s4.3.8-2.3 2.3c0 0 .3 8.2 8.2 2.5 0 0 4.7-3 8-4.2 0 0 1.6-.6 1.4-1.8 0 0 .2-1.5-1.5-1.1 0 0-1.4 0-2.3-.3 0 0-1-1.2-1.6-.8-.6.5-2.1.2-.9 1.7 1.2 1.4 1.5 1 2 .6.6-.4 3.1-1.4.9.7s-4.2-1.2-5-1.8zm-22 1h-2s-1 1.6-1.7-1l-.7 1.6s2.3 8.8 4.4-.6"/>
|
||||||
|
<path fill="#ffc221" d="M475.4 276.6s-1 5.8.3 9.2l21.1.5s-.2-4 0-9.7H494s-.5 4.6 0 7.5h-.5s-.4-4 0-7.5H491s-.4 4.3 0 7.5h-.5s-.4-3.7 0-7.5H488s-.5 3.9 0 7.5h-.6s-.5-3.9 0-7.5h-2.7s-.6 3.6 0 7.5h-.5s-.6-3.6 0-7.5h-2.7s-.6 4.2 0 7.5h-.6s-.4-4 .1-7.5h-2.5s-.7 3.5 0 7.5h-.7s-.4-3 .2-7.5zm22.3 10.4s-.5-10.2 1.4-13c2-2.6 2.5-2 5.8 0 3.4 2.2 7.7 4.5 8.5 4.8.6.3 1.6.5 1.6 2.4s.3 2.4-2.6 0a9 9 0 0 0-2.7-1.8c-2.6-.9.6.5 1.5 1.9.8 1 1.5 1-.6 1.5a219 219 0 0 0-12.9 4.2"/>
|
||||||
|
<path d="M505 279.6s-1.5-1.8.5-2.3c2-.4 2.1 3 2.5 5.1.3 2.2-2.5-2.1-2.8-2.7zm-2.7 9s-2.3.9-.7 1.6c1.4.7 5.5-2.7 4.2-2.5-1.6.3-3.5 1-3.5 1zm3-3s2-.3 1.6.5c-.3 1-1 .4-1.4.2-.3-.2-1.6-.7-.1-.8z"/>
|
||||||
|
<path fill="#ffc221" d="M516 282.8s.6 4 4 5c0 0 2 .4 1.5-1.3 0 0-.3-1.5-.6-2-.3-.7-1.6-1-1.8-1.1-.2 0-.3-.5.6-.2 1 .3 1 .4 1-.3s-.6-.4-1.4-.8c-.4-.2 0-.4.3-.3.4 0 1.3.3 1.3-1 0 0 .1-.8-.9-.8-1.1 0-1-.6-.7-.7.3 0 1.5.8 1.9-.6.3-1.3-1.6-.5-1.4-1.2.3-.8 1.7.3 1.7-.5.2-.8 1.3-1.1-.6-1.4-.9-.1 0-.6 1-.4 1 .1 1.6-1.2 2.3-1.6.6-.5 4.2-2.6-.6-1.9-4.7.7-6.1 3-6.3 3.5a13 13 0 0 0-1.3 7.6"/>
|
||||||
|
<path fill="#ffc221" d="M527 285.8c.7 0 1.2 0 1.4.5.8 1.6-1 1-2 2.2s-1 1-2.4.5c-1.4-.4-2-2.5-2-2.5 0-.7.5-.8 1.2-.6 0 0 2.3.2 3.9 0zm-5-.8s0 .4.9.5c.7 0 3 .3 4.5-.1 0 0 .4-.1.2-1 0 0 0-.7-1.2-.4-1.3.2-3 0-3.7-.1-.6-.2-1 0-.8 1zm-.2-2.9s-.1 1.3 1.1 1.4c1.3.2 2.9.2 3.5 0 .5 0 1.4-.2 1.5-1 0-.7.2-1.2-1.3-.8s-3.4 0-3.6 0c-.1 0-1.2-.3-1.2.4m.5-2.5s-.3.6-.2 1.2c0 .5.9.7 2.5.7s3-.2 3.2-.7c.1-.7.5-1.3-.7-1-1.3.1-3 .2-3.6 0-.6-.3-1.1-.4-1.2-.2"/>
|
||||||
|
<path fill="#5a3719" d="M582.1 286s0 1 .9 2.2l-45.2-1.3s.6-.4.8-2.2z"/>
|
||||||
|
<path fill="#ffc221" d="M522.7 277.8s-.4 1.1.4 1.4c.7.2 2.2.4 4 .1 0 0 1 0 1.3-1 .3-1 .3-.4-2.3-.8 0 0-.8-.3 1.5-.3 0 0 1.4 0 1.5-.2.3-.2 2-1.7-.3-1.5-2.3 0-1.1-.5 0-.5 1.2 0 1.6.3 2 0s0-.2-.7-.8-.1-.5.3-.1c.5.3.8.5 1.3 0s-.4-1.2 0-1c.3 0 .6.8 2 0 1.6-.7 3.5-.3 4 0 .6.5 2.2 1 3.1 0 1-.8-1.1-1.7-.3-1.8 1-.1 1.6.2 1.9-.5.4-.8-1.4-1.4.3-1.8 1.6-.5.2-5-.3-5.5 0 0-1.9 1.1-3.8 4.3-2.1 3.3-3.3 5.2-6 4.2-3.9-1.5-6 .6-6.5 1-1 .6 2 .8.2.9-1.7 0-1.7.2-1.8.4-.2.3 0 .5.3.6.3 0 .9.6-.1.6s-1.8-.3-1.5.9c0 0 0 .2.6.3.6 0 .8.8-.3.8-.8 0-.8.2-.8.3m4.1 11.3s-.7.5.3.6c1.2 0 1.7.3 2.1-.3s1.8-.4.8-1.2c-1-.8-1.6-.3-3.2 1z"/>
|
||||||
|
<path fill="#ffc221" d="M531.5 275.5s3.8-3.5 6.9-1.2c3.2 2.5 3.4 2.8 3.5 2.9 0 0 .4.3-.4 1-.9.8 0 .8.9.3s1 0 1.4.5c.5.5 1.1.8-.3.8h-4.6s-2.1.2-1-.7c1-.9.8-1.9.3-2-.6 0 0 .6-.3 1-.4.4-1.1.7-1.9.7s-1.4.6-.2 1c1.1.4-.2.7-.8.7s-3.5.2-.5.6c3 .3-.3.3 2 1.5 2.4 1.4.6 4.3-.3 4.6 0 0-1 .5.2.4 1.3-.2 2-.3 1 .4-.8.6-2.6 2.9-5 1.2 0 0-1.2-.6.8-.7 2 0-1.6-.5-2.3-1-.5-.3-3-2.7-1.5-2.5 1.6.4 1-.5.1-.8-.8-.3-1-1.6 0-1.4 1 .3 2 .9 3 .8.7 0 .5-.3-1.2-.8-1.7-.6-2.4-.7-2-2 .4-1.5 2.3.5 1.8-.6-.4-1-2-.5-1.2-1.9s1-.8 1.5-.6c.3.1 1 0-.1-.8-.8-.5 0-1.3.2-1.4"/>
|
||||||
|
<path d="M534.2 276.5s0-.5.8-.4c.6 0 .4-.2.6-.4.2 0 1.9.5.3 1-.6.3-1.6.2-1.6-.2z"/>
|
||||||
|
<path fill="#ffc221" d="M537.9 280.5s-1.3.6-.2 2c1 1 1 1.5 1 2.2-.1.8 43.4 1.3 43.4 1.3s0-2.9 1.8-4.5z"/>
|
||||||
|
<path fill="#5a3719" d="M582.8 285.2s.2-2.4 1.6-3.1c.7-.5 1.6-.3 2 1.6.6 2.7-1.7 5.1-2.7 4-1-1-.8-2.5-.8-2.5z"/>
|
||||||
|
<path fill="#7b3c20" d="M532.9 295.4s2.9-2.5 3.4-3.6c0 0 7.8 5.6 7.3.4l.2-2.6s2.9.3 3.3-2l-7.3-.3s-.8-.1-2 1.1c-1 1.2-3.4 2.5-5.5 1.4 0 0-1-.8-1.9 0-1 .5-1 .7-.2 1.5s2.4 2.9 2.7 4zm16.8-15.4-4.3-.2s-1.5-2.2-4.6-4.6c0 0-.9-.4.8-1.8 1.8-1.4 2.3-2.8 2.3-3.5 0-.6 0-1.7.6-1 .6.8 5 4.9 5.8 3.7.6-1.2.7-1.7.7-2.1.2-.4.3-1.5 1-.3.6 1.1 1 .8 1.1 3.8 0 0 0 3 .5 4 0 0-5.6-1.7-3.8 2zm-18.6-9.2s3.3 2 5-.6c1.5-2.5 2.6-2.8 1.4-5.3-1.2-2.3 0-3.4.9-4.4 1-1 1.8-.8 1.8-4.6.2-3.9 2.8-5 4-6.3 1.2-1.2 4.2-3-.4-3.7-4.4-.8-13.4-3-15.7-6.5-2.3-3.5-3.3-1.5-3.3-1.3 0 .2-.8 2.7 1.5 7.3s4.2 7.6 6.5 9c2.2 1.5 4.2 2.3 3 5.4s-3 8.6-4.7 11"/>
|
||||||
|
<path fill="#5a3719" d="M543.2 261s.6 8 6.3 10.8c0 0 1.3-3 .8-6.1 0 0 1.9.1 2.4 1 0 0 0-2.3-2.6-3.2-2.7-.7-1.4-6-.4-6.5.9-.6.6-1.7 0-2.6-.7-1-.8-2.3 1.4-1.7 2.3.6 2-.6.5-1.7-1.3-1.1-1.3-2.5.7-2.5s5-1.9 3.2-2.4c-2-.6-2.5-1.3 0-2 2.7-.8 4-1.7 2-2-2-.2-3.3-.9-1.4-1.2 1.9-.3-.3-2.3-2.5-2.3-2.3-.2-7 .7-3.3-2.3 3.8-3-5.4-.8-1.6-2.8 3.8-2-1.3-1.1-2-1.1s-.7 0-.4-1c.2-1-.5-1.6-1.7-.9-1 .6-1 .6-1-.7 0-1.5-1.3-.4-2.1 0-.9.3-3 1.9-3.9 1-.7-.9-1.2-1.7-3.8-.2-2.6 1.5-2 .2-2-.5s1-3.4-2.4-.5-.7-3-3.5-1c-2.7 2-3 2.4-3.5 1.5-.5-1-1-1.7-4 .3-3.1 1.9-.8-1.3-.5-2 .5-.6 1.8-5-.9-1.6 0 0-1.3 2.4-4.2-1.9 0 0-3 4.3-3.9 2.4-.8-2-1.5-2-2.6-.8-1 1.2-.2-.1-.7-1.2-.4-1-.7-2.9-5.8.8-5.1 3.8 1.8 1-2.1 2.7s-13.5 7-4.8 5.9c8.7-1.3-4.2 3.3-1.2 4.2 3 .8 2 3.5 13.4.3 11.2-3 9.4-.4 15.2-3 5.8-2.4-1.4.9 6.4.8 7.7-.2 1.3 0 2.8 1.6 1.5 1.6 8 5.3 14.1 6 6.1.6 7.7-1.7 5.9 1-1.8 2.6-2.4 3.6-3.4 4.6-1 .8-4 3-4 6.6 0 3.7-4.8 4.3-3 8.3l4.1-4z"/>
|
||||||
|
<path fill="#5a3719" d="M553.3 269.9s-1.4-1-1.4-2.8c0 0 1 .2 1.4.8 0 0 3.5-4-.8-5.4-4.1-1.4-2-5.3-.6-5.3s1.7-.3.4-2c-1.2-1.5-1-1.6 1.3-2 2.3-.4 2.1-1 1-1.5a7.7 7.7 0 0 1-1.9-1.6s6.8-2.9 4.6-4.3c-2.2-1.3 0-1 2-2.3 2-1.4 2.2-1.7 2.5-2.3 0 0-2 .3-3.4 0 0 0 1.7-.9 0-2.3s-2.3-2.6-5-2c-2.7.7-1.8-.2-.8-1.3s.6-1.7-1.3-2c0 0 .2-1.2 1.7-2.5 0 0-3.7.2-5-.4 0 0 1.6-1 1.6-2.3 0 0-2 .7-4.5.5 0 0 1.5-1.3 1.5-2.4 0 0-4.4 1-6.4 2.5 0 0-.5 0-.8-.6-.4-.4-.6-1-5.5.6 0 0 .5-2.2 1.7-3 1.2-.8 1-2.6-6.5 2.1 0 0-1-.6-1.9-2.9 0 0-1.7 2.3-2.9 3.1 0 0-1 .5-1-1 .2-1.5-.7-.5-1.4 0-.8.4-1.3 1.5-1-1.5s-1-3.6-1-3.6-2.3 3.3-3.7 3.7c0 0-2.5-2.4-3.4-4-.8-1.6-.8-2.2-1.7.6-.9 2.7-2 3-2 3s-1.5-1.3-1.6-2c0 0-.3.7-.8 1 0 0-1.3-1.5-1.2-3.7 0 0-8.2 4.5-9.2 7.2 0 0-7.7-.5-10.8.1 0 0 .7-2.4 2.7-3.7 0 0-2-.2-2-2.3 0 0 1.6.2 2.6 0s-1.4-3.1 1.1-3.2c2.5-.1 4.2 1.2 3-2.2-1-3.3-.6-3.3-.6-3.3s4.4 2.6 5.1 1.9c.8-.6-.5-2 3.4-1.4 3.9.7 2.8-1.5 4.4-1.7 1.5 0 2.3 1 1.3-6.1s4.8 3.5.9-7.2c0 0-1-3.3-3.3-4.7 0 0-.6 2.3-3.2.3-2.6-2-7.8-2.8-5.6-4.4 2.2-1.7 3.2-3.9 2.6-5.2 0 0-2.6 2.6-7 .7-3.6-1.5-4.4 1.3-8 .5 0 0 0-1 3.1-3.4 3-2.3-1.8.8-3.6 1.3-1.9.4-2.4 0 1.5-3.1 4-3 12-8.5 11-13 0 0 1.8 2.3 6.7.6 5-1.7 8.6-2.3 10-5a23 23 0 0 1 6.4-5.6c1.1-.5 2.4-1 .9 1.5-1.6 2.4-4 6.6-10.8 9.4-6.8 2.8-9.5 4.8-10.7 6.3-1.2 1.5-7.4 4.8-3.3 4.3 4-.7 10.9 0 7.6-1-3.2-.9-6.9.6-3.9-2.1 3-2.7 3.5-3.6 7.9-5.4 4.4-1.9 9.2-6.1 8.7-1.6-.5 4.4-8.6 9.1-10.6 10.6-2 1.4-1.2 1.2-1.2 1.8 0 .6-.4 1.8-1.2 2.3-.8.6-.5 1.2-.3 2.4.2 1.3-.2 1.8.4 2 .6.1 1.2.2 1.4 1.1.2 1 .6 1 1.8 1 1.1-.2 2 0 2 .6 0 .7 1.2 1.7 1.3-.4.1-2.2 1-2.5-1.2-1.5-2.2.9-2.6.6-2.6-.4s-.2-.8-1-.9c-1 0-1.3-1.3.3-2.2 1.6-.8 1.6 0 3.6-1.6 2-1.7 2-2 2.3-3 .3-.7-2.9 2.4-4.4 3-1.5.8-1-.5-.8-2.1.3-1.7 4-4 5.7-4 1.8 0 5.6 1 4 3.3-1.7 2.3-6.4 5.2-4.5 5.4 2.1.2 2.4-.6 3.6.4 1.2 1 0 3.2-.4 4.4a8.4 8.4 0 0 1-2.2 2.7s-2.2-3.8-2.1-.8c0 3.1-.5 4.2 0 4.3.5.1 2.8 1.7 3.6 1.7s-4.1 2.3-2 2.5c2 .1 5.3-1 6.4-3 0 0-4.2-1-5.9-2.6 0 0 4.9-1.2 3.5-5.8 0 0 4.9 1.3 2.7 3.5-2 2.1-3.3 1.8-1.5 2.4 1.9.6 2.7 1.2 2.7 1.2s1.3.6.5 1.6c-.7 1-.7 2.6 0 2.5.5 0 2.6-1 .9-2-1.8-1 1.9-.8.4-1.7-1.6-1-2-1.1-2.4-1.6-.5-.3 19.7-12.2 9.5-7.8 0 0 2.1-4.6 5.1-4.6 3 0 3.2 2.3 1.5 4.2-1.7 1.7-2.8 4.6-6.7 5.2 0 0 5.6 2.7-1 7.2 0 0-1.5.7-1 1.2s4.5-1.7 5-3a5.5 5.5 0 0 1 3-3 38 38 0 0 0 11.2-9.6c2.3-3.9 2.8-4 7.2-7.5s3.6-2.8 4.2-3.6c.5-.9.7-2.3 2.7-3.4 2-1.2 9.8-5.4 12.3-7.2 2.4-1.8 7.4-5 9.6-7.8 2.1-2.7 8-6.2 9.4-5.6 1.4.7-.2 2.8-3.5 5.4-3.5 2.5-12 9.3-13.3 10.4a44.7 44.7 0 0 1-11.2 6.6c-2.7.3-2.4 1.3-4 3s-5.3 5.4-6.5 6.4c-1.3 1-4.3 3-4.4 4.5-.2 1.5.5 1.6-1.9 3.8a50 50 0 0 1-11.9 8.1s4.5 1.6 1.8 4.6c-2.6 3-2.5 2.6-2.6 2.8 0 0 6.7-1 2 4.3 0 0-1 1.5 1.1 0 2.3-1.9 1.4-4.1 1-4.5 0 0 3.7-2.3 7.9-2.3 4.1 0 4-.3.2-1.4 0 0 2.7-3.2 5-1.6 2.2 1.5 1.4 2.5-.9 3.8-2.4 1.2-5.8 1.7-8.5 3.2 0 0 5 1 7.6-1.1 2.6-2 2.8-1 3-.6.5.4.8 1-.4 2.6-1.2 1.7-1.3 1.8-1.2 2.2 0 .4-.1 1.5-2.5 2-2.3.3-3.5 1.3-2.6 2.4.7 1.2.7 4-1.2 3.7-2-.3-1.6-1.9-2.3-2.5-.8-.6-1.9-1.5-5.4.2-3.6 1.9-3.8-.3-3.7-1.5 0 0-2.3 2-4.2.2-2-1.9-.2-2.6 1-3.5 1-1 5.5-3 2.8-2.5-2.7.3-6.7.4-7.6-1.6-1-2.1 2-1.9 2.4-1.7.5.2 2.3 1.7 2.5-.3 0-2 3-2.3 2-2.6-1-.4-2.5.9-2.9 1.3 0 0-2-2.9-5.4-2-3.4 1 1 .7 2 .8.8.2.3 1.8-2.7 4.6-3 2.7-1.7 1.8.5 1.8 2.3 0 7.9 0 4.6 2.6-3.2 2.7-4.5 4-6 3.6-1.8-.5 0-1.6.8-2.1s1.2-1.2-.4-.6c-1.6.5-2.1.7-3.4-1.5-1.2-2.3-.8-1.6-.2-3.1.6-1.5 1.8-3 .4-2.5-1.6.6-1.4.7-1.3-1 .1-1.7-1.7-2.1-1.7-2.1s.8 1.7.1 2.8c-.6 1-.7 1.4.4 1.6 1 .4 2 1.3.6 2.3-1.4.9-1.2.7-.4 1.3 1 .7 2.3 1.3.9 2.7-1.4 1.4-.3 1 .4 1 .8 0 2.3.6 2.3 2 0 1.2 0 1.5 2.3.3 2.3-1.3 6.7-1.1 6.7.6 0 1.8-.6 2.3 1.8.8 2.4-1.5 3.5 1.4 5.2 0 1.6-1.5 2.6-2.8 4.6-.4 1.9 2.4 1.3 3-1 4.8-2.3 1.7 1.1.4 2.9-.5s6.7-1.5 9.5-.2c2.9 1.2 3.7 1 5.8 0 2.1-.8 3.2-1 6.3 1.1 3.3 2.2 5.7 2.6 7.4 2.5 0 0-3.5 1.4-7.5 1.6-4 .3-6 1-6.7 1.6 0 0 2.3 1.5 2.8 3.2 0 0 2.6-.3 3.8.2 0 0-.6 1.9 1 2.9s2.7 1.4 1.5 2.7c-1.2 1.4 1.9.8.1 2.7-1.7 2-2.1 3-2.2 4.5 0 1.6.4 1.8-1.1 2-1.6.1.2 1.9-.5 4-.7 2-5 1.7-4.8 7.2 0 0 1.2-2.7 3.8-5 2.5-2.4 2.6-2.6 2.5-4 0-1.4-.1-1.1 1.2-2.2 1.4-1-.6-2 .8-3.6 1.3-1.5.2-1.2 1.8-2.7 1.5-1.6-1.5-1.7.2-3.3 1.5-1.6-4-3.5-2.3-4.5 1.5-1 4.3-2.4-5-2.3 0 0 2.3-3.6 10-2.9 0 0-2 1.6-2.2 3l1.6.5s-.4 1.1-1.9 2.3c0 0 4.2 2.3 4.9 3.8 0 0-2.6.8-3.3 1.8 0 0 1.2 1.3 1.6 3 0 0-2.9-.4-3.2 1.7-.4 2.1-1.3.7-1.3 2s.1 1.7-.9 2c-1 0-.1 1.1 0 1.8.2.7.6 2.3.4 2.8 0 0-1.5 0-2.1.2 0 0 .4 3-1.3 3.5-1.7.4 1 1-.9 1.3-1.8.3-1.5.5-3.7 4.5 0 0 1.9-1 3.8-2.4 2-1.3-.2-1 3-4 3.3-3.3 2.7-3.5 2.4-5.1-.2-1.6-.3-3 .9-4.5s1.5-3.2 5.7-3c0 0-1.2-2.8-2.7-3.5 0 0 2-1.3 4-1.5 0 0-1.8-2.3-5.6-4.4 0 0 3-2.6 3.9-3.9 0 0-1.5.3-2.7 0 0 0 .6-1.3 3.4-3.1 0 0 1.5 1.4 1.4 2.9 0 0 4.8-2.7 7.5-2.4 0 0 1.4 3.4-5.3 10 0 0 4.2.3 6 0 0 0-1 3.2-6 5-5 2 1 4.2-4.1 3.8-5-.4-3.5 1.3-3.4 3.9.2 2.6.3 5.3.2 6 0 0-4-1.3-4 2.6.1 4-2 4.8-2.5 5.1 0 0-1.2-1-3-1.7 0 0-2.5 4.9-6.5 7.7"/>
|
||||||
|
<path fill="#7b3c20" d="M547.4 220.5s1.4-.2 3.8 1.2c2.3 1.5 4.6-1.5 2-2.3-2.6-.8 0-1.8 2.4.2 2.4 1.9 3.3.9 4.2.3.8-.7 1.9-1.1.3-2.2-1.6-1.1 1-.6 2.3.3 1.2.7.7 1.5.6 1.7-.2.2-.3 2.9 2 .5 2.4-2.5 3.7-4.8 3.6-6 0 0 1.3.8 1.5 2.3.2 1.5 2-.8 2.6-1.6.6-.8 1.6-3 1.5-4.4 0 0 1.6 2.5 4 0s1.4-1 4.2-1.7a17.6 17.6 0 0 0 8.5-5.2c2-2.5 2.1-.8 4.6-1.4s7.7-4.2 8.2-6.1c.4-1.9.3-3.1-.4-2.4-.7.6-.4 0-1.5-.6-1.1-.7-2.7.9-2.7.9s1.6 1.2.3 1.7c-1.2.6-2.3 2.3-4.6 1.6-2.3-.6-4.8 2.2-4.8 2.2s2 1.6-.7 2.7c-2.7 1-2.3 1.4-3.9.2 0 0-2.9 3.7-4.6 4.5 0 0-.7 0-1.2-.8 0 0-2 2.1-2.8 2.5 0 0-1.3-1-2.3-1.5 0 0-2.3 2.9-4.2 3.7 0 0-.6-1-1.8-1.7 0 0-.6 3.6-4.6 5.8 0 0 .2-1-1.8-2.3 0 0-5 4.3-6.9 4.7-2 .4-.2-.9 0-1.5.4-.5 1.6-2.3-.8-3s-2 .5-2.5.7c-.6.2-.6-.4-2.2-.2s-1.3.9-2 1.2c-.8.2-3.6-.5-3.4 1.4.1 1.8 1.5 3.1-1 4.2-2.5 1 1 .8 4.1.4"/>
|
||||||
|
<path fill="#5a3719" d="M557.5 215.3s.6-2.5-1.5-3.5c0 0 13.2-2.1 3.2-7.2 0 0 11.9-2.3 9-6.1-2.7-3.8-5.4-3-5.8-3-.4 0 2.5-2.1 3.3-1.8.7.3 10.2 3.9 7.8.7-2.4-3-2.2-2.9-2.6-3.8 0 0 3.1 0 7.9 4.6 0 0 1-1 .9-2.9 0 0 3.3 1 4.4 2 0 0 .6-1.2.3-1.8 0 0 3 1.5 4 3.2 0 0 1.3-1.2 1.5-2.6 0 0 3 1.3 3.7 2.2 0 0 1-1.3.6-3 0 0 4.9 1.3 5.5-1.6 0 0 4.9 1 1.7 2.9-4 2.5-.4-.6-4.6 2.3-3.2 2.3-5 4.9-6.6 4.4-1.1-.5-2.5 2.9-4 1.3-1.5-1.7-1.5-1-2.7.7a24.5 24.5 0 0 1-2.8 3.5s-.8-.5-1.6-1.2c0 0-.8 1.6-2 2.8 0 0-1-1.3-2.6-2 0 0-2.3 2.7-3.8 3.7 0 0-1.4-1.4-2.9-1.9 0 0-.2 3.8-3.1 5.7 0 0-.6-1.2-2.6-2 0 0-1.4 2.3-4.6 4.3z"/>
|
||||||
|
<path fill="#5a3719" d="M550.6 209.5s-1.6 1.2-.6 2.5c1 1.4 1.1-.2 2.4-.3 1.3-.2 17.5-3 2.8-7.3 0 0 .7-.6 3.1-.8 2.6-.3 11.8-2.7 7.5-6-4.3-3.2-7.9 1.1-4.3-2.8 3-3.1.6-4.6.6-4.6s-8.5 5.6-10.4 6.7c-1.8 1-4.6 3-1.4 4 3.3 1 5.4-3.4 5.7-2.4.3 1-6.4 4.8-5.4 6.4.9 1.8.7 3.3 2.4 2.9 1.6-.3 6 .8 2.5.7-3.7-.1-5 1-5 1z"/>
|
||||||
|
<path d="M556.4 201.3s-1.5 1.1.5.6 5.9-1.4 5.2-2.4c-.8-1-3.4.2-5.7 1.8"/>
|
||||||
|
<path fill="#7b3c20" d="M582.4 184.5s7.5-.2 10.5 1.9c3 2 4.6 3.4 5.5 3.8 0 0-.1 2.8-5 .7 0 0 .4 1.4-.2 2.8 0 0-1.7-1.2-3.8-1.7 0 0-.4 1-1 1.7 0 0-2.1-2.2-4.6-2.9 0 0-.4 1.1-.8 1.6 0 0-2.6-1.6-4.6-1.6 0 0 .4 1.7 0 2.3 0 0-5.4-4.3-10.3-3.8 0 0 2.3 3.5 3.8 5.1 0 0-9.8-.7-8-6 1.5-5.3-.2-4 6.2-4z"/>
|
||||||
|
<path fill="#5a3719" d="M536.3 199.1s-1.1 1 0 1.7c1.1.8 5-2 5.5-2.4.6-.4 2-.4 0 1.1s-3.8 3-5 4.6c0 0 6.4-1.8 10.6-5.4 4.3-3.7-.1-1.3 7-4.8 7.3-3.5 11.1-9.1 7.2-8.5-3.8.6-7.3 5-10.4 6.7-3 1.7-4.7 2-4.2 1 .4-1 2.7-.6 6.9-4 4.1-3.3 3.2-3 3.2-4.2 0-1.2-1.5-4 4.7-7.4 6.2-3.3 25.6-14.5 27.3-18.5 0 0-5.7.6-13.2 6.1a69.7 69.7 0 0 1-13.4 8.8c-2 .8-1.8.2-3.1 1.9a171.8 171.8 0 0 1-10.1 9.8c-1.3 1-1.8 1.6-1.9 3.8 0 1-8.4 7.3-11 9.7z"/>
|
||||||
|
<path fill="#5a3719" d="M562 184.3s-1.5.6-3 0c-1.3-.7-.8-3.6 2.5-5.5a50 50 0 0 1 12.6-4.8s-.5 3.8-10.1 7.1c0 0 .6 2-2 3.2"/>
|
||||||
|
<path fill="#aa5323" d="M565.4 181.8s.3 1 0 1.8c0 0 17.9 1.7 27.1-9.2 0 0-12.7 1.2-17.7 4.3 0 0 3.2-4 12.7-7.3s13.4-7.4 14.2-9.7c0 0-12 4.3-17.7 4.3 0 0-1.2 0-2.3.5-1.1.7-8.8 6.2-10.8 7.2 0 0 4.3-.4 5.9-1.8 0 0-3 8-11.4 10z"/>
|
||||||
|
<path fill="#5a3719" d="M531 192s-2.3 1.7-1.3 2.4c1 .9 2.6 1 6-1.9 3.6-3 12.1-10.2 6.8-10.5 0 0-7-.4-6.7 3.8.2 4.2-4.4 6-4.8 6.2m-15.9-2.5s4.6 2.7 2.8 4.9c0 0 14-11.8 10-14.4-3.8-2.6-6.9 2.3-6 2.7 1 .6 3-.4 2.3.6a77.4 77.4 0 0 1-9 6.2zm-3.6-3.8s3 1 3.2 2.3c.1 1.2 9.2-6.4 6.8-9.7-1.1-1.5-6-2-6.4.8-.2 3 4.6-.3 3 1.8-2.2 2.7-5.9 4.4-6.6 4.8m32.6-6.4s-1.9 1.4-.1 2.3c1.8.7 2.8-.6 3.7-1.3.9-.8 5.3-4 6.2-6 1-2 2.5-2.7 4.1-3.7 1.5-1 12.6-6.6 19.5-12.7 6.8-6.1 4-4.5 11-8.4s11.7-7.5 13.2-11.8c0 0-3.3 1-6.2 3a243 243 0 0 1-10.7 6.5c-1.3.5-3 .6-4 1.6s-1 2.3-4.3 5c-3.5 2.9-21 15.3-23.2 17a334.7 334.7 0 0 0-9.2 8.5"/>
|
||||||
|
<path fill="#aa5323" d="M530 183.4s2-1 5.6-.8c3.6.2 17.8-13.6 22-16.3a341.6 341.6 0 0 0 18.7-13.8c1.8-1.8 2-3.6 3.6-4.6 1.5-1 3-.9 6.4-2.9 3.5-2 20.3-12 19.3-17.8 0 0-25 15-30.7 19.8a375 375 0 0 1-24.7 17.7c-2.8 1.9-5 5-9.9 8.8-4.7 3.9-9.5 7.2-10.3 10z"/>
|
||||||
|
<path fill="#aa5323" d="M524.8 178s4.6-.4 5.2 1.9c0 0 10-6.8 12.2-9.6 2.2-2.8-.8-1.2 4.9-4.9a594.3 594.3 0 0 0 27-19.1c2.7-2.3 7.8-5.5 11.9-8.2 4-2.8 19.9-10.6 18.1-17l-14.2 9.5c-2.7 1.8-3.9.8-6.5 2.9-2.6 2.1-8.5 6.3-9.5 7.6a161.3 161.3 0 0 1-14.4 11c-4.4 3-14.1 8.6-18.9 12.8a602.8 602.8 0 0 1-15.8 13.2z"/>
|
||||||
|
<path fill="#aa5323" d="M510.4 176.8s2.3 0 3.1.9c0 0 4.3-3.9 8.9 0 0 0 16.8-11.5 18.5-14.4 1.7-2.8 4.5-2.9 11-7.6 6.6-4.8 10.7-6.9 15-10.2 4.4-3.4 8.1-7.3 11.2-9.3 3.1-2 11-7.2 9.8-11.7 0 0-6.5 3.6-10.6 8.2-4.2 4.6-3.8.7-8.1 4.5a82.7 82.7 0 0 1-16.3 11.7c-5.5 2.7-2.2 2.4-6.2 5-3.9 2.5-3.6 2-5 2.5a10 10 0 0 0-5.1 3c-1.6 1.7-5.4 4-9.6 6.5a106.5 106.5 0 0 0-16.6 10.9"/>
|
||||||
|
<path fill="#aa5323" d="M515.5 168s-1-1.9.7-3.3c1.6-1.3 4.6-4.8 5-7 .5-2.3.1-1.9 4.8-3.8a188.2 188.2 0 0 0 38.2-21.6c1.8-1.5 6.4-4.6 8.3-6.2 0 0 .8 2.5-1.2 4.2a222.1 222.1 0 0 1-21.5 14.9 76.6 76.6 0 0 0-9.6 5.5c-1.9 1.6-1.6 2-10.2 6.3-8.5 4.1-9 4.6-8.7 4.9.3.4 4.2-1.3 6-2.4 1.8-1 8.8-4.3 11-6a69 69 0 0 1 7-5 296 296 0 0 0 18-11.2c3.5-2.7 4.5-3.5 5.3-3 .7.4 2 .4.4 2s-6.7 6-8.7 7.3c-2 1.3-8.1 5-9.8 5.8-1.7 1-2.4 2.5-3.4 3.2-.9.8-3.7 2.7-7 3.5s-4 3.3-6.3 4.8c-2.3 1.4-18 10-18.5 10.3 0 0 .9-1 .2-3.2"/>
|
||||||
|
<path fill="#aa5323" d="M570.3 132.4s-.9.8-.4 1.2c.6.6 2.8 2.2 5.6-.6a106.5 106.5 0 0 1 12.5-10c2.3-1.5 3.6-2.8 3.5-4.7 0 0-11.4 6-21.2 14.1m15.6-1s1.7-2.9 6-5.7c4.3-2.7 10.8-6.7 11.5-7.6 0 0 1.6 1.7-1.7 3.8a311.7 311.7 0 0 0-10.8 7c-.7.7-2 1.6-5 2.5"/>
|
||||||
|
<path fill="#7b3c20" d="M499 163s-4.8 2.6-3.1 4.2c1.7 1.5 4.2 1 5.4.6l3.2-1c.4 0 4.5-1.3 5.6-3.2 1-2 3.8-4.2 6-5.8 2.2-1.5 3-3.2 2.7-4.3zm-28.4 22s3.4-2 8-.7c0 0-.2-1.1-1-1.7 0 0 5.7-1.5 6.9-4 1.2-2.5 1.5-2 2.5-2.6 1.2-.8 8.7-6.8 7.8-8.1-.9-1.3-1-3.1-1.7-3.7 0 0-1.5 2.1-9 5.7-7.2 3.6-15.4 6.2-21.4 14.2-5.9 8-5.3 12.6 2 14.7 0 0 5-3.2 17.6-2.1 12.4 1.1 16.6 5.8 17.4 6.6.8.9 3.3 4 .9 9.1 0 0 2.5 1.1 2.6-1.3.3-2.3.4-1.8 1-1.5.6.4 1.3.5 1-1.4a18 18 0 0 0-2.3-7.2c-1.1-1.5.2-.8.9-.6.7.3 3.3 2.5 1.8-1.5-1.5-3.8-2-2-2-1.8 0 .3-.4 1.2-3.8-1.4a26.8 26.8 0 0 0-8.8-4.4c-2.3-.6-.7-.6.7-1 1.4-.6 3.1-.8 3.8-2.4 0 0-1.4.3-3.8-.6a13.2 13.2 0 0 0-11.5 2s1.2-4.5-2.5-4.3c-3.6.3-6.2.2-10 3.2 0 0-.3-4.6 3.4-7 3.7-2.5 3.2-1 5.2-1.6 2-.6 2.3-2.7 1.4-3.4 0 0 4.8.9 12.8-5.8 0 0-4.3 5.7-9.5 6.8 0 0-.9 3-5.7 3.7-4.8.7-4.6 3.4-4.6 4.1z"/>
|
||||||
|
<path fill="#5a3719" d="M457 212.7s2.2-14.1 15.5-15.1c11.4-1 15.1.5 17.4 1.3s8 2.4 5.8 4.2c-2.2 1.8-3.5 1.5-3.5 1.5s2.5-2.9.2-3.3c-2.3-.5-2.4.9-2.7 2-.4 1.3-.5 2.6-1.6 3.6 0 0-1.2-1.4-2.9-.2s-.2 1.2.5 1c.6-.2 1.5-.5 1.3.5-.1 1-1 2.8-3.8 4.2-2.7 1.4-2.6 1.3-5.8 1.9-3.3.5-6.3 1.8-10.5 5.3-4.2 3.6-8.7 2.4-9.6-1.5-.8-3.4-.4-5.4-.4-5.4z"/>
|
||||||
|
<path d="M472 212.2s1.2-2.7-1-4c0 0-6.9 1.2-9-.9 0 0 7.5-.4 12.2-2.3 4.6-1.7 3.3-3 1.7-3.4-1.6-.3-4.6.5-4.9 2 0 0-1-1.6.2-2.6a5 5 0 0 1 4.7-.8c1.7.4 3.1 1.2 8.6-1.6 0 0 3.1.7 3.3 2.8 0 2.2-.3 3-.6 3.3-.2.4-.6 1-1.3 1-.6-.2-1.5-.3-2.3 1.2a8.9 8.9 0 0 1-2.6 3.7s1.5-4.6-2.5-5.7c0 0-3.3 2-5.8 2.1 0 0 3.2 3-.8 5.2z"/>
|
||||||
|
<path fill="#5a3719" d="M479.3 203.8s-1.6-1.6.4-1.9c2-.1 4.6 1.4 4.2 2.7-.6 1.2-3 1.1-4.6-.8"/>
|
||||||
|
<path fill="#fff" d="M592.6 181.6s-3.7 1-.2 3.3c3.4 2.3 5 4.2 7.5 4.8 2.5.7 5 1.5 5 4s-.5 3.5-1.9 5.2c-1.3 1.7.8 2.4 2.6 1.5 1.8-1 3.3-1.5 4.5-2.2 1.1-.7 3-.6 1.4.3-1.9 1-3.7 1.5-1.4 1.5 2.3.1 16.2.4 19.1-.6 3-.9 6.8-1.2 7-5 0 0 .2-1.6 1.3-2.4 1-.7 1.8-2.3.2-1.2-1.5 1.1-2.7 1.7-3 1.3-.3-.3-.5-.6.7-1.1 1.2-.5 1.8 0 2.9-1.5 1-1.6 1-1.4.4-2-.6-.6-1.8-1-1.2-1.8.6-.8 1.2-3-1.4-1.7-2.5 1.3-7.6 4.8-10 5.3-2.2.5-4 1.2-7.1 1.8-3 .7-5 1.4-8.5 3.3-3.3 1.8-3-1.1-2.5-1.5 0 0 1.3 2.3 4.7-.7 3.4-2.9 2.3-.1 10.6-2.9 8.3-2.7 6.3-3.1 9.5-4.8 3.3-1.7 6.4-1.8 4-4-2.2-2.4-2.4-2.5-5.3 0a34.5 34.5 0 0 1-16 6.4s18.8-8 16.9-9.2a20.8 20.8 0 0 0-5.3-2.5c-1.3-.3-1.7-.6-4.7.8-3 1.3-3.5 1.6-4.3 1.7-1 0-3.5.6-7 2.4-3.6 1.9-5.5 2.6-8 4 0 0 1.7-3.3 9.1-5.5 7.4-2.2 11.1-4.2 10.4-4.6s-2.7-.8-4-.5c-1.5.3-1-.1-5.6 1.7-4.5 1.8-2.6 1.4-6.2 2.2-3.7.7-5.1 1.5-6.9 2.3 0 0 .8-1 3.1-1.9 1.3-.4-1.3-.9 2.2-1h1a32.2 32.2 0 0 0 8.6-3.3c-.7-.1-5.1-.6-9.5 1.6-4.5 2-2.5 1.3-4 1.5-1.6.4-5 2.4-6 3.4-1.1.9-2.7 1.5-2.7 1.5z"/>
|
||||||
|
<path fill="#5a3719" d="M482.7 201.8s1.8.5 2.2 1.8c.5 1.2 1.6-.6 1.6-1.1-.1-.6-1.2-3-3-1.9-2 1.1-1 1.1-.8 1.2"/>
|
||||||
|
<path fill="#7b3c20" d="M477.9 226s3.7-1.8 6.9-1.5c0 0-1.3-4.4.9-3.7 2.1.8 1.5.4 2 .4 0 0 .1-2.9-.5-4 0 0 2.3.5 4.6.5 0 0-2.2-4.1.2-7a6.9 6.9 0 0 0 4.2 3.4v-2.3s1.7-.3 3 .4c1.4.8 2.5-7.6-1.5-9.3 0 0-1 1.5-4.7 2.3s-3.7 1.5-5.2 4.3-3 2.9-6.2 5c-3 2-5 6-5 6.4 0 0 1.5 2 1.3 5.1"/>
|
||||||
|
<path fill="#999" d="M603.1 177.8c1.3-.2-1.4-.9 2-1h1a32.2 32.2 0 0 0 8.7-3.3c-.7-.1-5.1-.6-9.5 1.5s-2.5 1.4-4 1.7c-1.6.3-5 2.3-6 3.3-1.1.9-2.7 1.5-2.7 1.5s-3.7 1-.2 3.4c3.4 2.3 5 4.2 7.5 4.8 2.5.7 5 1.5 5 4a7 7 0 0 1-1.9 5.2c-1.3 1.7.8 2.4 2.6 1.5 1.8-1 3.3-1.5 4.5-2.2 1.1-.7 3-.6 1.3.3-1.8 1-3.6 1.5-1.3 1.5 2.3.1 16.2.4 19.1-.6 3-.9 6.8-1.2 7-5 0 0 .2-1.6 1.3-2.4 1-.7 1.8-2.3.2-1.2-1.5 1.1-2.8 1.7-3 1.3-.3-.3-.5-.6.7-1.1 1.2-.5 1.8 0 2.9-1.5 1-1.6 1-1.4.4-2l-1-.9s-.9-.7-1.9-.1a27.6 27.6 0 0 1-7 2.7c-1.6.1-3.5.9-6.5 2.4s-8.2 4.6-9 1.7l-2.8 1c-3.4 1.8-3-.7-2.5-1.4 0 0-1.8 2-1.7.2 0-1.8 1.2-1.5 3.3-2.1 2-.6 5-2 3.8-3-1.3-1-2.7 1-4.2 1.8-1.4.7-4.3 1.2-4.8-.9-.4-2.1-.4-3.6-4.3-3.8-4-.2-3.9-2.7-2.8-3.8 1.1-1 2-2.8 5.7-3.5z"/>
|
||||||
|
<path d="M615.6 196.9s6.1-2.8 11.7-4.1c5.7-1.3 1.2.2.3.4-1 .3-9.7 3.2-11.8 4.2-2 1-1.7.2-.2-.4zm1.4 1.3s6.9-2.3 8.2-1.4c1.3 1 .2.6-1.3.8-1.6.1-5.7.8-6.8.8-1.1 0-.1-.2-.1-.2m11-2.5s1.4-.2 1.5.4c.1.4-.6.5-1.3.4-.7-.1-1.3-.5-.1-.8z"/>
|
||||||
|
<path fill="#fff" d="M446 255.9s-.3-6.2 2.8-9.2 17.8-18.5 20.1-22.8c0 0 2 1.3 2 3.8 0 0 2.5-4.3 4.5-6 0 0 1.7 1.8 1.5 5.4 0 0 3.5-1.9 9-1.9 0 0-2 2.4-2.1 3.9 0 0 7.6-1 11.7-.3 0 0-10.6 6-7.7 6.5 3.1.5 6.2 0 6.2 0s-3.4 3.3-8.8 4c0 0 6.9 0 8.2 1.5 0 0-6.6 1-12 5 0 0-.4-.2-.4-1.7 0 0-.3 1.4-1.8 2.7-1.5 1.2-5.1 4-6.5 5.3-1.4 1.3-3.8 4-6.6 4 0 0 .6-2.1-1.4-2.8a5.7 5.7 0 0 0-6 1.5s-7 .1-9.4.5c0 0 1.6-2.6 3-2.6 1.6 0 7.6 1 8.1-3.2.6-4-3.8-3-2.2-5.4 1.7-2.4 1.3-2.2 1.4-2.6 0 0-1.4.8-2.2 3a10.7 10.7 0 0 1-4.2 6 15 15 0 0 0-4.8 5s-1.3 0-2.4.4"/>
|
||||||
|
<path fill="#fff" d="M452.8 252.2s.3-.8 2.3-1.2c2.2-.3 2.3-1.3 2-1.8-.3-.4-1.5-.4.5-2.8 0 0 .8.3 1.2.8.6.6 2.9 5.5-6 5"/>
|
||||||
|
<path fill="#999" d="M447.9 247.9c0 4 5.3 2.5 5.3 2.5a21.4 21.4 0 0 0-3.8 3.6c.4-2-3-2.5-3-2.5a12.3 12.3 0 0 1 1.5-3.6m19.3-21.5 1.7-2.5s2 1.3 2 3.8c0 0 2.5-4.3 4.5-6 0 0 1.7 1.8 1.5 5.4 0 0 3.5-1.9 9-1.9 0 0-2 2.4-2.1 3.9 0 0 7.6-1 11.7-.3 0 0-10.6 6-7.7 6.5 3.1.5 6.2 0 6.2 0s-3.4 3.3-8.8 4c0 0 6.9 0 8.2 1.5 0 0-2 .3-4.6 1.2 0 0-1.9-1.8-7.6-1.5 0 0 4.4-2.6 8-3.4 0 0-1.6-2-4-.1 0 0-4.9-3.3-.8-6.2 0 0-2.8-.5-4.7.8 0 0 0-2.3 2.1-3.3 0 0-5.4-1-6.7 3 0 0-1-1.5-.5-3.4 0 0-3.3 1.9-4.8 4 0 0-.5-4-2.6-5.5M456.8 252c-.9.2-2.3.3-4 .2 0 0 .2-.5 1.3-1 0 0 .4.8 2.7.8"/>
|
||||||
|
<path d="M466.6 236.7s2.5 2 3.3 3c0 0 2.3-1.4 3-2.7 0 0 1.9 1.1 2.4 2.8 0 0 1.3-.8 1.5-2 0 0 3 .6 4.2 1.6 0 0 .4-3 0-4.8 0 0 2.1.2 3.4.7 0 0-1.2-2 5-4.5 0 0-4.7 1.1-6.5 3 0 0-2 .2-2.9-.4v4.5s-1.2-.6-3.5-1.1c0 0-.6 1-1 1.2 0 0-1.5-1.2-2.1-2.7 0 0-2.3 2.1-3 3 0 0-2.3-1.6-3.8-1.6"/>
|
||||||
|
<path fill="#ffc221" d="M452.5 267.3s1 .4 3.3-1.4 8.7-5.9 9.2-9.2c.7-3.3-2-3.4-4-2.5-2.2 1-1.3 2.7-1.2 3.4 0 .6.2 2.9-3.3 6z"/>
|
||||||
|
<path fill="#ffc221" d="M451.9 268.3s-5.2-2.2-.6-4.5 6.7-2.9 7.2-4.9c.6-1.9.2-1.5-1.5-.7-1.7.7-8.2 3.8-9.2 1 0 0 2.7 1 6-.6 3.4-1.7 6.2-2.1 4-2.8a37 37 0 0 0-11 .5c-1.4.4-1 .3-1.3 1.6-.2 1.3-1.6 4-2.2 4.7-.5.8-1.8 4 .6 5.5a8.6 8.6 0 0 0 8 .2"/>
|
||||||
|
<path d="M449.9 257s-1.3.2-1 .7c.2.5.5.4 1 .4.3 0 1-.2 1.1-.5 0-.3-.8-.7-1.1-.5z"/>
|
||||||
|
<path fill="#fff" d="M451.5 267.1s-2.4-1.1.4-2.6c2.7-1.5 5.6-3 6-3.6 0 0-1.3 1.9-6.4 6.2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 32 KiB |