'integer', 'size' => 'integer', 'width' => 'integer', 'height' => 'integer', 'is_shorts' => '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(); } // Accessors public function getUrlAttribute() { return asset('storage/videos/'.$this->filename); } public function getThumbnailUrlAttribute() { if ($this->thumbnail) { return asset('storage/thumbnails/'.$this->thumbnail); } // Return null when no thumbnail - social platforms will use their own preview 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(); } // Get shareable URL for the video public function getShareUrlAttribute() { return route('videos.show', $this->id); } // 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'; } // 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(); } // 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->id); } // Get secure share URL public function getSecureShareUrlAttribute() { return secure_url(route('videos.show', $this->id)); } // 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); } }