ghassan a4384113c2 Audio songs: one-folder storage, version-aware download/share, GPU-checked renders
Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

24 KiB

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

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

php artisan migrate
php artisan db:seed
php artisan tinker

Background Workers

# 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

./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().

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
  • MatchPointtimestamp_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, 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
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.

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 codeapp/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.

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

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

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
└── videos/
    └── {song-slug}/            ← ONE folder per song/video. EVERYTHING for it lives here.
        │                          ┌──────────────── SOURCE OF TRUTH (synced to NAS) ───────┐
        ├── {title-slug}.{ext}              ← primary track / video file (canonical name)
        ├── {song-slug}-{lang}-{id}.{ext}   ← each extra-language audio track (one per language)
        ├── slides/
        │   └── {position}.{ext}            ← cover image(s) / slideshow frames
        ├── thumb.{ext}                     ← cover for video-type uploads that have no slides
        ├── meta.json                       ← {id, user_id, title, created_at}
        │                          └──────────────────────────────────────────────────────┘
        └── cache/                  ← REGENERABLE renders. LOCAL-ONLY. Never on NAS. Safe to wipe.
            ├── video.mp4                   ← generated "Download Video" (plain)
            ├── video-viz.mp4               ← generated "Download Video" (visualizer)
            └── hls/
                └── {variant}/…             ← adaptive-streaming rendition (.m3u8 + .ts)

Sources vs. the cache/ subfolder — a hard rule:

  • The song-folder root holds only the source of truth (primary track, extra-language tracks, slides, thumb, meta.json). These are what gets pushed to / pulled from the NAS.
  • cache/ holds only regenerable, derived renders — the "Download Video" mp4s and the HLS rendition. It is LOCAL-only and is NEVER pushed to the NAS (syncVideo() pushes only the source files). Deleting cache/ (or the whole subtree) is always safe — it rebuilds on the next download/stream. There must be no shared public/slideshow or public/hls caches anymore; every render lives under its song's cache/.
  • DB pointers: videos.slideshow_video_pathusers/.../{song}/cache/video.mp4; videos.hls_pathusers/.../{song}/cache/hls.
  • Reclaim space anytime with php artisan nas:free-local-storage (deletes song cache/ folders); tracks:reorganize never treats anything under cache/ as an orphan.

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

  • One folder per song/video at users/{slug}/videos/{song-slug}/. There is NO tracks/ subfolder — every audio track (primary AND secondary) sits directly in this folder.
  • Primary keeps the canonical {title-slug}.{ext} (the name the NAS layer reconstructs in uploadDirectToNas() / syncVideo() — do not change this scheme).
  • Each extra-language track is {song-slug}-{lang}-{db-track-id}.{ext}. The DB id makes every track filename globally unique, so no two tracks can ever overwrite each other even within the same folder/language.
  • Slides are slides/{position}.{ext}.

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

File type Path (relative to NAS root and to storage/app/) Served via
Primary video / audio users/{slug}/videos/{song-slug}/{title-slug}.{ext} NasSyncService::ensureLocalCopy()
Extra audio track users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext} NasSyncService::ensureLocalTrackCopy()
Video thumbnail users/{slug}/videos/{song-slug}/thumb.{ext} MediaController::thumbnail + ensureLocalAsset()
Slides users/{slug}/videos/{song-slug}/slides/{n}.{ext} MediaController::thumbnail + ensureLocalAsset()
Playlist thumbnail users/{slug}/playlists/{playlist-id}/thumb.{ext} MediaController::thumbnail + ensureLocalAsset()
Avatar users/{slug}/profile/avatar.{ext} MediaController::avatar + ensureLocalAsset()
Banner users/{slug}/profile/cover.{ext} MediaController::banner + ensureLocalAsset()
Post images users/{slug}/posts/{post-id}/{filename} MediaController::postImage + ensureLocalAsset()

The one-time migration php artisan tracks:reorganize enforces this layout for existing songs (dry-run by default; --force to apply).

The 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. 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)