# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**TAKEONE** (Play) is a Laravel 10 video-sharing platform with sports match annotation, HLS adaptive streaming, GPU-accelerated video processing, playlists, threaded comments, and a super-admin panel. Live at `https://video.takeone.bh`.

Tech stack: PHP 8.1+, Laravel 10, Blade templating, Vite 5, Axios, FFmpeg/FFProbe with NVIDIA NVENC, SQLite (dev) / MySQL (prod), Laravel Sanctum.

---

## Essential Commands

### Local Development
```bash
php artisan serve          # Backend on http://localhost:8000
npm run dev                # Vite dev server with HMR
npm run build              # Production frontend build → public/build/
```

### Database
```bash
php artisan migrate
php artisan db:seed
php artisan tinker
```

### Background Workers
```bash
# Video processing (CompressVideoJob, GenerateHlsJob)
php artisan queue:work --queue=video-processing

# Orphaned file cleanup scheduler (every CLEANUP_INTERVAL_MINUTES, default 30)
php artisan schedule:run   # run this every minute via cron
php artisan cleanup:orphaned-videos --dry-run   # preview only
php artisan cleanup:orphaned-videos --force      # delete orphans
```

### Testing
```bash
./vendor/bin/phpunit
./vendor/bin/phpunit --filter "TestName"
./vendor/bin/phpunit tests/Feature/ExampleTest.php
```
Tests use in-memory SQLite, array mail/cache/session drivers, sync queue, and BCRYPT_ROUNDS=4. Set `APP_ENV=testing`.

---

## Architecture

### Role System
Three roles stored on `users.role`: `super_admin`, `admin`, `user` (null = user). The `IsSuperAdmin` middleware guards all `/admin/*` routes. Helper methods on the User model: `isSuperAdmin()`, `isAdmin()`, `isUser()`.

### Video Lifecycle
1. `VideoController@store` validates upload, stores file, extracts metadata via FFProbe (duration, dimensions, orientation).
2. Status column transitions: `pending → processing → ready` (or `failed`).
3. `CompressVideoJob` re-encodes with NVIDIA NVENC (`h264_nvenc`, CRF 23), replaces original if smaller.
4. `GenerateHlsJob` produces 480p / 720p / 1080p HLS variants (`h264_nvenc`, preset p4) as `.m3u8` + `.ts` files.
5. Streaming served at `GET /videos/{video}/hls/{file?}` (master playlist → segments).

Queue connection is `sync` by default (runs inline); switch `QUEUE_CONNECTION=database` or `redis` for true async.

### Shorts Auto-Detection
A video is a Short when duration ≤ 60 s **and** portrait orientation. The `is_shorts` DB column allows manual override. Use model scopes `Video::shorts()` / `Video::notShorts()`.

### Trending Algorithm
`Video::scopeTrending($hours=48, $limit=50)` — weighted score: 70% recent views, 15% view velocity, 10% recency bonus, 5% likes. Only applies to videos < 10 days old with ≥ 5 recent views.

### Playlist System
- `playlist_videos` pivot carries `position`, `watched_seconds`, `watched`, `added_at`, `last_watched_at`.
- Each user has one `is_default = true` playlist ("Watch Later").
- `Playlist` model methods: `addVideo()`, `removeVideo()`, `reorder()`, `getNextVideo()`, `getPreviousVideo()`, `updateWatchProgress()`, `canView()`, `canEdit()`.

### Comment Threading & Mentions
Comments have a `parent_id` for one-level threading (replies). The `parsed_body` accessor converts `@username` syntax into clickable profile links.

### Comment Timestamp Badges
The `enhanceBody()` function in `components/video-comments.blade.php` converts timestamp syntax written in comments into clickable `._comment-time-badge` spans at render time (client-side). Supported formats:
- `@mm:ss` — single timestamp, jumps to that point (colon separator)
- `@mm.ss` — same, dot separator (legacy, still supported)
- `@mm:ss-mm:ss` or `@mm.ss-mm.ss` — time range; plays from start to end then pauses

Clicking a badge: scrolls to `#ytpWrap` (smooth), waits 500 ms, seeks `#videoPlayer` to `start`, calls `.play()`, and if a range is specified stops at `end` via a `timeupdate` listener. `@username` mention detection requires the first char to be a letter so it never collides with numeric timestamps.

### Sports Match Annotation
Videos of type `match` support three related models:
- `MatchRound` — round number, name, `start_time_seconds`
- `MatchPoint` — `timestamp_seconds`, action, competitor (blue/red), running score
- `CoachReview` — time-range segment with coach note and emoji

All managed via `MatchEventController` under authenticated routes.

### Key Model Scopes & Accessors
`Video` scopes: `public()`, `visibleTo($user)`, `shorts()`, `notShorts()`, `trending()`.
`Video` accessors: `url`, `thumbnail_url`, `like_count`, `view_count`, `formatted_duration`, `iso_duration`, `open_graph_image`.
`Playlist` accessors: `thumbnail_url`, `video_count`, `total_duration`, `formatted_duration`.

## Rules

**Never navigate between videos with a page refresh** — all video-to-video transitions (Up Next recommendations, playlist tracks, prev/next) must use JavaScript SPA transitions. Never use `window.location.href` or `<a>` tags with hard navigation for video card clicks. The established pattern is:
- **Video player (generic, match types):** `recTransitionTo(url)` / `plTransitionTo(url)` — fetch `/videos/{key}/player-data` JSON, call `window._ytpLoadSource(hlsUrl, mp4Url)`, then `recSwapContent(url)` / `plSwapContent(url)` in the background to update description, comments, and sidebar.
- **Audio player (music type):** same pattern but swap `audio.src` and `audio.play()` instead of `_ytpLoadSource`.
- **Sidebar cards** must have `data-rec-url` (Up Next) or `data-pl-id` (playlist) attributes and call `recGoTo(url)` / `plGoTo(url)` onclick — never `window.location.href`.
- **Autoplay on track end** is wired via `window._plOnVideoEnd` (video) or `window._plOnTrackEnd` (audio) hooks — the player calls these hooks on `ended`; the SPA script sets them.
- The only fallback to `window.location.href` is inside `catch` blocks when the fetch itself fails.

**Database changes require confirmation** — if any task requires a migration, schema change, or new column, always ask before proceeding.

**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.

**All buttons must use the global `.action-btn` system** — never add custom button CSS. Use these classes:
- `.action-btn` — default (bordered, bg-secondary)
- `.action-btn.action-btn-primary` or `.action-btn.primary` — brand red, for primary/submit actions
- `.action-btn.action-btn-danger` or `.action-btn.danger` — red border/text, for destructive actions
- `.action-btn.icon-only` — square padding, for icon-only buttons

Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</span></button>`. The global CSS lives in `layouts/app.blade.php`.

**Mobile layout uses a native-app scroll model** — on `max-width: 768px`, `html` and `body` are locked (`overflow: hidden; position: fixed`) so the window never scrolls. `.yt-main` is `position: fixed` spanning `top: 56px` to `bottom: calc(56px + env(safe-area-inset-bottom))` with `overflow-y: auto; -webkit-overflow-scrolling: touch`. This keeps the header and bottom nav truly fixed without any JavaScript. Consequences to remember:
- **Never use `position: sticky` inside `.yt-main` on mobile** — sticky elements float over content because the scroll container changed. Override with `position: relative !important` in the mobile media query.
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.

**Every new Blade page must ship desktop + mobile style partials from day one.** When you create a view at `resources/views/<scope>/<page>.blade.php`, also create:

```
resources/views/<scope>/partials/<page>/styles/
├── desktop.blade.php   ← base + desktop CSS (the foundation)
└── mobile.blade.php    ← @media (max-width: 768px) and below
```

…and wire them up in the page's `@section('extra_styles')` block:

```blade
@section('extra_styles')
@php
    // Any shared Blade variables consumed by BOTH partials (palette,
    // computed sizes, theme values) must be defined here, not inside
    // a partial — each @include runs in its own variable scope.
@endphp
@include('<scope>.partials.<page>.styles.desktop')
@include('<scope>.partials.<page>.styles.mobile')
@endsection
```

Rules that must never be violated:

1. **Never put a `@media (max-width: ...)` block inside `desktop.blade.php`.** All mobile-scoped rules go in `mobile.blade.php`. The whole point is that editing one cannot affect the other.
2. **Never put a non-media-query rule inside `mobile.blade.php`.** Every selector in the mobile partial must be inside an `@media (max-width: 768px)` (or smaller) block. A naked rule would leak to desktop.
3. **Folder name is `styles/`, not `styles.`.** Laravel resolves dots in `@include('foo.bar.baz')` as directory separators, so a file called `styles.mobile.blade.php` can't be referenced by `@include('....styles.mobile')`. Always put the two files under a `styles/` subdirectory.
4. **Shared `@php` variables go in the parent page**, never duplicated across partials. Define `$hue`, palette values, computed sizes, etc. in the page's `@section('extra_styles')` block above the `@include`s, so both partials inherit them.
5. **Reference example:** the channel page (`resources/views/user/channel.blade.php` + `resources/views/user/partials/channel/styles/{desktop,mobile}.blade.php`) is the canonical implementation. Mirror its structure on every new page.
6. **No “I'll add mobile styles later.”** A page without a mobile partial is not finished. Create `mobile.blade.php` with at minimum the empty media-query scaffold:
   ```blade
   <style>
   @media (max-width: 768px) {
       /* mobile overrides for <page> */
   }
   @media (max-width: 480px) {
       /* small-phone refinements */
   }
   </style>
   ```
   …even when there are no mobile-specific rules yet. This guarantees the file exists for the next person who needs to tweak mobile.
7. **When editing an existing page that still has inline styles**, refactor it to this structure as part of the same task — don't add new CSS to a page that hasn't been split yet without splitting it first.

**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**

**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** — this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.

**Never build custom dropdowns for country, nationality, phone code, timezone, currency, or language** — reusable Blade components already exist for these. Always use them; never roll a new `<select>`, inline list, or custom picker:

| Need | Component | Stored value |
|---|---|---|
| Country / nationality picker | `<x-country-select name="…" />` | ISO2 code e.g. `"BH"` |
| Phone / dial-code picker | `<x-phone-code-select name="…" />` | `"+973|BH"` — split on `|` to get code alone |
| Timezone picker | `<x-timezone-select name="…" />` | IANA string e.g. `"Asia/Bahrain"` |
| Currency | read from `App\Data\Countries::all()[$iso2]['currency']` | ISO 4217 code e.g. `"BHD"` |
| Language picker | `<x-language-select name="…" />` | ISO 639-1 code e.g. `"ar"`, `"en"` |

All four select components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. Country/phone/timezone data lives in `app/Data/Countries.php`; language data lives in `app/Data/Languages.php`. Usage is tracked in `.claude/component-usage.md` — add a row to the relevant table whenever you place one of these components in a view.

**All sharing must go through the share component — never build a custom share UI or duplicate the modal.** There is exactly one share modal, and one way to trigger it:

| Need | Use | Notes |
|---|---|---|
| The share modal itself | `<x-share-modal />` | Singleton — already rendered once in `layouts/app.blade.php`. Never copy its markup or `@include` it again on a page that uses the app layout. Lives in `resources/views/components/share-modal.blade.php`. |
| A share button / menu item | `<x-share-button :video="$video" />` | Pass `tag="a"` for dropdown items (e.g. video-card menu), default `tag="button"` for `.action-btn`. Extra classes/attributes are forwarded; a slot overrides the default "Share" label. |

The button calls the global `openShareModal(shareUrl, title, recordUrl, emailUrl, membersUrl)`, which provides copy-link, social, **send-by-email**, and **share-to-members** (in-app notification + email) in one place. Rules that must never be violated:

1. **Never render a raw share `<a onclick="openShareModal(...)">`** — use `<x-share-button>` so every entry point passes the full argument set.
2. **If you must call `openShareModal()` from JS, pass all five arguments** — including `route('videos.shareEmail', $video)` and `route('videos.shareMembers', $video)` (empty string for guests). Calling it with partial args silently drops the email/members options.
3. **Never build a second share modal, dropdown, or sheet** anywhere. New share entry points reuse `<x-share-button>`.
4. **Share-to-members** is powered by `VideoController@shareWithMembers` + the `VideoSharedWithUser` notification (database + mail) and the `users.search` typeahead — extend these rather than adding a parallel path.

**Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception:

1. **Creating a new reusable component** → add a new section to the tracker listing the component file path, its props, and an empty usage table.
2. **Placing a component in any view** → immediately add a row to the relevant tracker table (view file path, field/slot name, any relevant notes). Do this in the same task, not later.
3. **Modifying a component** (props, markup, CSS, JS, behaviour) → open the tracker first, read every row in that component's usage table, then apply the necessary follow-up changes to every listed view before marking the task done. Never modify a component without checking its tracker entries.
4. **Removing a component from a view** → delete its row from the tracker table in the same task.
5. **Deleting a component entirely** → remove its full section from the tracker and clean up every view that was still referencing it.

The tracker is the source of truth for blast radius. If the tracker is out of date and a change breaks an unlisted page, that is a process failure — always keep it accurate.

**Always use the self-hosted flag-icons library for every flag in the project — never use emoji flags or external CDN flag sources.**

The flag-icons v7.2.3 library is self-hosted at `public/vendor/flag-icons/` (CSS + 270 SVG files). It is loaded synchronously in both layouts:
- Front-end: `resources/views/layouts/app.blade.php` via `asset('vendor/flag-icons/css/flag-icons.min.css')`
- Admin: `resources/views/admin/layout.blade.php` via the same asset path

Rules that must never be violated:

1. **Never use emoji flags** (`🇧🇭`, `🇺🇸`, etc.) anywhere — they are invisible on Windows Chrome/Firefox. Always use `<span class="fi fi-{iso2}"></span>` where `{iso2}` is a lowercase two-letter country code (e.g. `bh`, `us`, `gb`).

2. **Never load flag-icons from a CDN** (`jsdelivr`, `unpkg`, `flagcdn.com`, etc.). The library is already self-hosted; adding a CDN link creates a duplicate load and a network dependency.

3. **`Countries::all()` `flag` field is a lowercase ISO2 code** — `app/Data/Countries.php` generates this via `$f = fn(string $c) => strtolower($c)`. Do not change it back to emoji. Every component that renders `$opt['flag']` already wraps it in `<span class="fi fi-{{ $opt['flag'] }}"></span>`.

4. **`Languages::all()` `flag` field is also a lowercase ISO2 code** — e.g. Arabic → `'sa'`, English → `'gb'`. Render it the same way.

5. **When updating JS that copies a selected flag into a button icon**, always use `innerHTML`, not `textContent` — the flag is now an HTML `<span>`, not a text character. The `_pick()` method in the custom-select components already does this.

6. **For the admin chart flag overlays** (`admin/dashboard.blade.php`), use `<span class="fi fi-{code}">` elements positioned absolutely — not `<img>` tags from `flagcdn.com`.

7. **Unknown/missing country fallback**: always use `<span class="fi fi-xx"></span>` — the library's own built-in placeholder (the SVG exists at `public/vendor/flag-icons/flags/4x3/xx.svg`). Never use any external icon, emoji, or Bootstrap Icons globe as a fallback. In Blade: `<span class="fi fi-{{ $flag ?: 'xx' }}"></span>`. In JS: `` `<span class="fi fi-${flag || 'xx'}"></span>` ``.

**Match highlights sidebar must always match the video player height** — use a `ResizeObserver` on `#ytpWrap` to write `--sidebar-height` to `document.documentElement` and bind `.events-sidebar { height: var(--sidebar-height) }`. Never hardcode a pixel or viewport height for the sidebar. The pattern lives in `videos/types/match.blade.php` (`initSidebarHeightSync`).

### NAS with automatic local fallback

**NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.**

#### Framework storage lives at `data/`, not `storage/`

**The Laravel framework storage directory has been relocated from `storage/` to `data/` so the only entry named `storage` in the project tree is the `public/storage` symlink.**

Layout (verbatim):
- `data/app/` → file storage (with `app/public/` exposed to the web).
- `data/framework/` → sessions, cache, compiled views, route cache.
- `data/logs/` → application logs.
- `public/storage` → a **symlink** to `../data/app/public`. It exposes public files to the web without making the rest of `data/` reachable.

The redirect is wired in `bootstrap/app.php`:
```php
$app->useStoragePath(base_path('data'));
```

This means every `storage_path(...)` call, `Storage::disk('local'|'public')` operation, session/cache/view/log write, and the local NAS file cache resolves through `data/`. The `storage/` directory at the project root **does not exist** and must never be re-created.

Rules:
1. Never re-create `storage/` at the project root. Laravel's storage path is `data/`. The framework can't run without `data/`.
2. Never delete the `data/` directory or any subdirectory of it.
3. Never replace the `public/storage` symlink with a real directory or copy files into it. It must remain a symlink targeting `../data/app/public`.
4. Never move user files into `public/storage` directly. All file writes go through the `data/app/` tree (NAS-mirrored paths), and the symlink + `MediaController` handle public serving.
5. If `public/storage` is missing or broken, fix it with `ln -sfn ../data/app/public public/storage`. Do not run `php artisan storage:link` blindly — that targets `data/app/public` which doesn't exist; you'd have to pass `--relative` and a custom target.
6. Nginx must alias `/storage` to `/var/www/videoplatform/data/app/public` (set in `/etc/nginx/sites-enabled/videoplatform`). If you ever edit that file, keep the alias pointing at `data/`, not `storage/`.

#### Canonical storage layout — IDENTICAL on local disk and on the NAS

**This is the single source-of-truth file structure. Both the local `data/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**

**Type-segregated top-level folders.** Every video has a `type` (`music`, `match`, `generic`). The folder it lives under is determined by that type and is frozen at upload time — editing a video's type does NOT move its files. The mapping is:

| Video `type` | Folder | Has `tracks/` subfolder? |
|---|---|---|
| `music` | `music/` | **Yes** — every track (primary + extras) lives in its own subfolder |
| `match` (sports) | `sports/` | No — single video file in the slug folder |
| `generic` | `videos/` | No — single video file in the slug folder |

```
users/{user-slug}/
├── profile/
│   ├── avatar.{ext}
│   └── cover.{ext}                                  ← banner (DB column is users.banner)
├── playlists/
│   └── {playlist-id}/
│       └── thumb.{ext}
├── posts/
│   └── {post-id}/
│       └── {filename}                               ← post images
│
├── music/                                           ← type = music
│   └── {song-slug}/                                 ← ONE folder per song
│       ├── meta.json                                ← {id, user_id, title, type:"music", created_at}
│       └── tracks/
│           ├── {primary-lang}-{primary-track-id}/   ← primary track has its own folder
│           │   │                          ┌─── SOURCE OF TRUTH (synced to NAS) ───┐
│           │   ├── audio.{ext}            ← the audio file (canonical name)
│           │   ├── lyrics.ass             ← synced lyrics for THIS track
│           │   ├── thumb.{ext}            ← cover when this track has no slides
│           │   ├── slides/
│           │   │   └── {position}.{ext}   ← THIS track's slideshow frames
│           │   │                          └────────────────────────────────────────┘
│           │   └── cache/                  ← LOCAL-only, regenerable, never on NAS
│           │       ├── video.mp4
│           │       ├── video-viz.mp4
│           │       └── hls/{variant}/…
│           └── {extra-lang}-{extra-track-id}/       ← every extra-language track, same shape
│               ├── audio.{ext}
│               ├── lyrics.ass
│               ├── thumb.{ext}
│               ├── slides/{position}.{ext}
│               └── cache/…
│
├── sports/                                          ← type = match
│   └── {match-slug}/
│       │                          ┌─── SOURCE OF TRUTH ───┐
│       ├── meta.json
│       ├── video.{ext}                              ← the match video
│       ├── thumb.{ext}
│       │                          └────────────────────────┘
│       └── cache/                                   ← LOCAL-only
│           └── hls/{variant}/…
│
└── videos/                                          ← type = generic
    └── {video-slug}/
        │                          ┌─── SOURCE OF TRUTH ───┐
        ├── meta.json
        ├── video.{ext}
        ├── thumb.{ext}
        │                          └────────────────────────┘
        └── cache/                                   ← LOCAL-only
            └── hls/{variant}/…
```

**Sources vs. the `cache/` subfolder — a hard rule:**
- The track-folder root (or video-folder root for sports/generic) holds only the **source of truth** (audio/video file, slides, thumb, lyrics, meta.json). These are what gets pushed to / pulled from the NAS.
- **`cache/` holds only regenerable, derived renders** — "Download Video" mp4s, the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (the sync layer pushes only source files). Deleting `cache/` is always safe; it rebuilds on next download/stream.
- DB pointers: `videos.slideshow_video_path` → `users/.../<track-folder>/cache/video.mp4` (music) or `users/.../<video-folder>/cache/video.mp4` (sports/generic). `videos.hls_path` → the parent `cache/hls` for that file.
- Reclaim space anytime with `php artisan nas:free-local-storage`; `tracks:reorganize` never treats anything under `cache/` as an orphan.

**Naming rules (apply on both local and NAS, always lowercase, Unicode-preserving slug via `NasSyncService::titleSlug()`):**

- **Music — one song folder, one folder per track inside it:**
  - Song folder: `users/{slug}/music/{song-slug}/`.
  - Track folder name: `{lang}-{db-track-id}` (e.g. `en-12`, `ar-47`). The DB id makes the folder name globally unique even when two tracks share a language.
  - Inside each track folder, filenames are **canonical** — `audio.{ext}`, `lyrics.ass`, `thumb.{ext}`, `slides/{position}.{ext}`. **Do not** put track-id or language in these filenames; the *folder* already disambiguates.
- **Sports (match):** `users/{slug}/sports/{match-slug}/video.{ext}`, `thumb.{ext}`.
- **Generic:** `users/{slug}/videos/{video-slug}/video.{ext}`, `thumb.{ext}`.

**Type is frozen at upload time.** When a user edits a video's type (e.g. `generic` → `music`), the on-disk folder does NOT move. The path remains under the original type folder for the life of that record. New uploads use the type-aware path. The migration command (`tracks:reorganize`) is the only thing that may move files between type folders.

File types and their canonical locations (same string on NAS and local):

| File type | Path (relative to NAS root and to `data/app/`) | Served via |
|---|---|---|
| Music primary audio | `users/{slug}/music/{song-slug}/tracks/{lang}-{primary-id}/audio.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Music extra audio track | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/audio.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
| Music track lyrics | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/lyrics.ass` | `NasSyncService::getLocalLyrics()` |
| Music track slides | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Music track thumb | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Sports video | `users/{slug}/sports/{match-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Sports thumb | `users/{slug}/sports/{match-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Generic video | `users/{slug}/videos/{video-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Generic thumb | `users/{slug}/videos/{video-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |

The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs/videos (dry-run by default; `--force` to apply). It moves any pre-existing flat-layout content into the type-segregated, per-track-folder layout above and updates the DB pointers in lockstep.

**Slide sharing across tracks (music only):** A music track owns its own slides via `video_slides.audio_track_id`. If a track has no slides of its own, the player and the render pipeline fall back via `Video::slidesForTrack($trackId)` to: (1) the primary track's slides, then (2) any other track's slides, then (3) the cover image. Files are never duplicated to support this — the fallback is purely a query/runtime concern.

The only files that live permanently on local disk are HLS segments (`data/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.

**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
- `data/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `data/app/public/avatars/` — formerly held user avatars; now NAS only
- `data/app/public/videos/` — formerly held uploaded video files; now NAS only

These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.

**Absolute rules — these must never be violated:**

1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.

2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
   - Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
   - Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
   - Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
   - Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
   - Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
   - Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`

3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.

4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.

5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.

6. **Always check `NasSyncService::isEnabled()` before doing a NAS operation.** It returns `false` when NAS is unreachable (TCP port-445 check, cached 2 minutes) or when the setting is disabled. Code with `if ($nas->isEnabled())` branches that fall back to local storage is correct — the `nas:auto-sync` scheduler will migrate local files to NAS when it comes back online.

**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
3. Delete files with no DB match (orphans) directly with `@unlink()`
4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)

### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
- **Broadcasting**: Pusher is configured but `BroadcastServiceProvider` is commented out — not active.
- **Timezone**: `Asia/Bahrain` (set in `config/app.php`).
- **App name constant**: `config('app.name')` returns `TAKEONE`.

### Route Structure Summary
- Public: `/`, `/videos`, `/trending`, `/shorts`, `/videos/search`, `/videos/{video}`, stream/hls/download
- Auth-required: video CRUD, likes, comments, profile, settings, history, playlists, match events
- Admin (`/admin/*`, `super_admin` middleware): dashboard, user CRUD, video CRUD, orphan cleanup
- API: `GET /api/user` (Sanctum token auth)
