216 lines
18 KiB
Markdown
216 lines
18 KiB
Markdown
# 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.
|
|
|
|
**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, or currency** — 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"` |
|
|
|
|
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.
|
|
|
|
**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.
|
|
|
|
**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 is always enabled — it is the only storage backend
|
|
|
|
**The NAS is permanently enabled in this project. Local disk is never a storage destination — it is a temporary write buffer only. Every user file must end up on the NAS and be served from the NAS. No exceptions.**
|
|
|
|
File types and their NAS locations:
|
|
|
|
| File type | NAS path | Served via |
|
|
|---|---|---|
|
|
| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
|
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
|
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{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/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
|
|
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
|
|
|
|
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
|
|
|
|
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
|
|
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
|
|
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
|
|
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
|
|
|
|
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. **Never check `NasSyncService::isEnabled()` before doing a NAS operation in this project.** It is always enabled. Writing code with an `if ($nas->isEnabled())` branch that falls back to local-only storage will result in broken files the moment that branch is taken.
|
|
|
|
**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)
|