'integer', 'size' => 'integer', 'width' => 'integer', 'height' => 'integer', 'is_shorts' => 'boolean', 'has_hls' => 'boolean', ]; // Relationships public function user() { return $this->belongsTo(User::class); } public function likes() { return $this->belongsToMany(User::class, 'video_likes')->withTimestamps(); } public function viewers() { return $this->belongsToMany(User::class, 'video_views') ->withPivot('watched_at') ->withTimestamps(); } public function slides() { return $this->hasMany(VideoSlide::class)->orderBy('position'); } public function audioTracks() { return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id'); } public function hasSlideshow(): bool { return $this->slides()->count() > 1; } /** * Resolve the slide list for a given audio track, applying the sharing rule: * 1. Slides explicitly owned by this track (audio_track_id = $trackId). * 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy). * 3. Slides owned by any other track (first track in id order). * 4. Empty (caller falls back to cover image). * * Pass `null` for the primary audio (the one stored on the videos row). * * @return \Illuminate\Support\Collection */ public function slidesForTrack(?int $audioTrackId) { $all = $this->slides; // eager-loaded collection of every slide if ($audioTrackId !== null) { $own = $all->where('audio_track_id', $audioTrackId)->values(); if ($own->isNotEmpty()) return $own; } // Primary / song-wide bucket (audio_track_id IS NULL). $primary = $all->whereNull('audio_track_id')->values(); if ($primary->isNotEmpty()) return $primary; // Borrow from the first track (by id) that has any. $byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id'); if ($byTrack->isNotEmpty()) { $firstTrackId = $byTrack->keys()->sort()->first(); return $byTrack[$firstTrackId]->values(); } return collect(); } // ── Local filesystem path helpers ───────────────────────────────────────── /** * Absolute path to the video file on local disk. * Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…"). */ public function localVideoPath(): string { return storage_path('app/' . $this->path); } /** * Absolute path to the thumbnail on local disk. * Old format: storage/app/public/thumbnails/{filename} * New format: storage/app/{relative-path} (path contains a slash, e.g. users/…/thumb.jpg) */ public function localThumbnailPath(): ?string { if (! $this->thumbnail) return null; return str_contains($this->thumbnail, '/') ? storage_path('app/' . $this->thumbnail) : storage_path('app/public/thumbnails/' . $this->thumbnail); } /** * Storage::delete()-compatible key for the thumbnail. */ public function thumbnailStorageKey(): ?string { if (! $this->thumbnail) return null; return str_contains($this->thumbnail, '/') ? $this->thumbnail : 'public/thumbnails/' . $this->thumbnail; } // Accessors public function getUrlAttribute() { return asset('storage/videos/'.$this->filename); } public function getThumbnailUrlAttribute(): ?string { if ($this->thumbnail) { return route('media.thumbnail', $this->thumbnail); } return null; } // Check if video is liked by user public function isLikedBy($user) { if (! $user) { return false; } return $this->likes()->where('user_id', $user->id)->exists(); } // Get like count public function getLikeCountAttribute() { return $this->likes()->count(); } // Get view count - use direct query to avoid timestamp issues public function getViewCountAttribute() { return \DB::table('video_views')->where('video_id', $this->id)->count(); } // ── Short opaque URL encoding ───────────────────────────────────── // 3-round Feistel cipher over 30-bit space → base62. // Consecutive IDs produce completely different output. Pure PHP, no extensions. private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; private static function feistelRound(int $n, int $round): int { // Deterministic pseudo-random function per round (no extension needed) $keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083]; return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF); } public static function encodeId(int $id): string { // Split 30-bit id into two 15-bit halves $L = ($id >> 15) & 0x7FFF; $R = $id & 0x7FFF; // 3 Feistel rounds for ($i = 0; $i < 3; $i++) { $tmp = $L ^ self::feistelRound($R, $i); $L = $R; $R = $tmp; } $n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF); $base = strlen(self::URL_ALPHABET); $out = ''; do { $out = self::URL_ALPHABET[$n % $base] . $out; $n = intdiv($n, $base); } while ($n > 0); // Pad to fixed length so all URLs look uniform return str_pad($out, 5, '0', STR_PAD_LEFT); } public static function decodeId(string $encoded): ?int { $base = strlen(self::URL_ALPHABET); $n = 0; for ($i = 0, $len = strlen($encoded); $i < $len; $i++) { $pos = strpos(self::URL_ALPHABET, $encoded[$i]); if ($pos === false) return null; $n = $n * $base + $pos; } // Reverse Feistel $L = ($n >> 15) & 0x7FFF; $R = $n & 0x7FFF; for ($i = 2; $i >= 0; $i--) { $tmp = $R ^ self::feistelRound($L, $i); $R = $L; $L = $tmp; } return (($L & 0x7FFF) << 15) | ($R & 0x7FFF); } // Route model binding — use encoded ID in URLs public function getRouteKey() { return self::encodeId($this->id); } public function getRouteKeyName() { return 'id'; } public function resolveRouteBinding($value, $field = null) { $realId = self::decodeId((string) $value); if ($realId === null || $realId <= 0) { abort(404); } return $this->where('id', $realId)->firstOrFail(); } // All share URLs use the unguessable token route public function getShareUrlAttribute() { return route('videos.showByToken', $this->share_token); } // Get formatted duration (e.g., "1:30" or "0:45" for shorts) public function getFormattedDurationAttribute() { if (! $this->duration) { return null; } $minutes = floor($this->duration / 60); $seconds = $this->duration % 60; return sprintf('%d:%02d', $minutes, $seconds); } // Get Shorts badge public function getShortsBadgeAttribute() { if ($this->isShorts()) { return [ 'icon' => 'bi-collection-play-fill', 'label' => 'SHORTS', 'color' => '#ff0000', ]; } return null; } // Visibility helpers public function isPublic() { return $this->visibility === 'public'; } public function isUnlisted() { return $this->visibility === 'unlisted'; } public function isPrivate() { return $this->visibility === 'private'; } // Check if user can view this video public function canView($user = null) { // Owner can always view if ($user && $this->user_id === $user->id) { return true; } // Public and unlisted videos can be viewed by anyone return in_array($this->visibility, ['public', 'unlisted']); } // Check if video is shareable public function isShareable() { // Only public and unlisted videos are shareable return in_array($this->visibility, ['public', 'unlisted']); } // Scope for public videos (home page, search) public function scopePublic($query) { return $query->where('visibility', 'public'); } // Scope for videos visible to a specific user public function scopeVisibleTo($query, $user = null) { if ($user) { return $query->where(function ($q) use ($user) { $q->where('visibility', '!=', 'private') ->orWhere('user_id', $user->id); }); } return $query->where('visibility', '!=', 'private'); } // Video type helpers public function getTypeIconAttribute() { return match ($this->type) { 'music' => 'bi-music-note', 'match' => 'bi-trophy', default => 'bi-film', }; } public function getTypeSymbolAttribute() { return match ($this->type) { 'music' => '🎵', 'match' => '🏆', default => '🎬', }; } public function isGeneric() { return $this->type === 'generic'; } public function isMusic() { return $this->type === 'music'; } public function isMatch() { return $this->type === 'match'; } public function isAudioOnly(): bool { $ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION)); return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']); } // Shorts helpers public function isShorts() { return $this->is_shorts === true || $this->is_shorts === 1 || $this->is_shorts === '1'; } // Scope for shorts videos public function scopeShorts($query) { return $query->where('is_shorts', true); } // Scope for non-shorts videos public function scopeNotShorts($query) { return $query->where('is_shorts', false); } // Check if video qualifies as shorts (auto-detection) public function qualifiesAsShorts() { // Shorts: duration <= 60 seconds AND portrait orientation return $this->duration <= 60 && $this->orientation === 'portrait'; } // Comments relationship public function comments() { return $this->hasMany(Comment::class)->latest(); } public function getCommentCountAttribute() { return $this->comments()->count(); } // Match events relationships public function matchRounds() { return $this->hasMany(MatchRound::class)->orderBy('round_number'); } public function matchPoints() { return $this->hasMany(MatchPoint::class); } public function coachReviews() { return $this->hasMany(CoachReview::class)->orderBy('start_time_seconds'); } // Get recent views count (within hours) public function getRecentViews($hours = 48) { return \DB::table('video_views') ->where('video_id', $this->id) ->where('watched_at', '>=', now()->subHours($hours)) ->count(); } // Get views in last 24 hours (for velocity calculation) public function getViewsLast24Hours() { return $this->getRecentViews(24); } // Calculate trending score (YouTube-style algorithm) public function getTrendingScore($hours = 48) { $recentViews = $this->getRecentViews($hours); // Don't include videos older than 10 days if ($this->created_at->diffInDays(now()) > 10) { return 0; } // Don't include videos with no recent views if ($recentViews < 5) { return 0; } // Calculate view velocity (views per hour in last 48 hours) $velocity = $recentViews / $hours; // Recency bonus: newer videos get a boost $ageHours = $this->created_at->diffInHours(now()); $recencyBonus = max(0, 1 - ($ageHours / 240)); // Decreases over 10 days // Like count bonus $likeBonus = $this->like_count * 0.1; // Calculate final score // Weight: 70% recent views, 15% velocity, 10% recency, 5% likes $score = ($recentViews * 0.70) + ($velocity * 100 * 0.15) + ($recencyBonus * 50 * 0.10) + ($likeBonus * 0.05); return round($score, 2); } // Get thumbnail dimensions for Open Graph public function getThumbnailWidthAttribute() { // Default OG recommended size is 1200x630 return $this->width ?? 1280; } public function getThumbnailHeightAttribute() { // Default OG recommended size is 1200x630 return $this->height ?? 720; } // Get video stream URL for Open Graph public function getStreamUrlAttribute() { return route('videos.stream', $this); } // Get secure share URL public function getSecureShareUrlAttribute() { return secure_url(route('videos.show', $this)); } // Get secure thumbnail URL public function getSecureThumbnailUrlAttribute() { if ($this->thumbnail) { return secure_asset('storage/thumbnails/'.$this->thumbnail); } return null; } // Get full thumbnail URL with dimensions for Open Graph public function getOpenGraphImageAttribute() { $thumbnail = $this->thumbnail_url; // Add cache busting for dynamic thumbnails if ($this->thumbnail) { $thumbnail .= '?v='.$this->updated_at->timestamp; } return $thumbnail; } // Get author/uploader name public function getAuthorNameAttribute() { return $this->user ? $this->user->name : config('app.name'); } // Get video duration in ISO 8601 format for Open Graph public function getIsoDurationAttribute() { if (! $this->duration) { return null; } $hours = floor($this->duration / 3600); $minutes = floor(($this->duration % 3600) / 60); $seconds = $this->duration % 60; if ($hours > 0) { return sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds); } elseif ($minutes > 0) { return sprintf('PT%dM%dS', $minutes, $seconds); } return sprintf('PT%dS', $seconds); } // Scope for trending videos public function scopeTrending($query, $hours = 48, $limit = 50) { return $query->public() ->where('status', 'ready') ->where('created_at', '>=', now()->subDays(10)) ->orderByDesc(\DB::raw('( (SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) * 0.70 + (SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) / '.$hours.' * 100 * 0.15 + GREATEST(0, TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 + (SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05 )')) ->limit($limit); } }