'boolean', 'view_count' => 'integer', ]; // Relationships public function user() { return $this->belongsTo(User::class); } public function videos() { return $this->belongsToMany(Video::class, 'playlist_videos') ->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at') ->orderBy('position'); } // Get videos with their pivot data public function getVideosWithPivot() { return $this->videos()->orderBy('position')->get(); } // Accessors public function getThumbnailUrlAttribute(): string { if ($this->thumbnail) { return route('media.thumbnail', $this->thumbnail); } return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200'; } // Get total video count public function getVideoCountAttribute() { return $this->videos()->count(); } // Get total duration of all videos in playlist public function getTotalDurationAttribute() { return $this->videos()->sum('duration'); } // Get formatted total duration (e.g., "2h 30m") public function getFormattedDurationAttribute() { $totalSeconds = $this->total_duration; if (! $totalSeconds) { return '0m'; } $hours = floor($totalSeconds / 3600); $minutes = floor(($totalSeconds % 3600) / 60); if ($hours > 0) { return "{$hours}h {$minutes}m"; } return "{$minutes}m"; } // Total of every viewer-session aggregated across the playlist's videos. // Kept for the analytics-style "video time watched" metric — for the // playlist's OWN view counter (cards, share link), use $playlist->view_count // which is incremented per-device by bumpViewIfNew(). public function getTotalViewsAttribute() { return $this->videos()->get()->sum('view_count'); } /** * Record a playlist view if this viewer hasn't already counted within the * dedup window. Mirrors the video_views pattern: signed-in users dedup by * user_id, guests dedup by device fingerprint (preferred) or device cookie. * * Cheap and idempotent — runs as one EXISTS + (optionally) one INSERT + * one atomic increment, all on indexed columns. Called via * dispatchAfterResponse() so it never adds latency to the page render. */ public function bumpViewIfNew(\Illuminate\Http\Request $request): void { $userId = \Illuminate\Support\Facades\Auth::id(); $did = $request->cookie('_did'); $fp = $request->cookie('_fp'); $fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null; // No identifier at all? Skip silently — a unidentifiable request would // count on every refresh and inflate the counter. if (! $userId && ! $did && ! $fp) return; $q = \DB::table('playlist_views') ->where('playlist_id', $this->id) ->where('viewed_at', '>', now()->subHour()); if ($userId) { $q->where('user_id', $userId); } else { $q->whereNull('user_id')->where(function ($w) use ($fp, $did) { if ($fp) $w->orWhere('device_hash', $fp); if ($did) $w->orWhere('device_id', $did); }); } if ($q->exists()) return; $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $geo = \App\Services\GeoIpService::lookup($ip); \DB::table('playlist_views')->insert([ 'playlist_id' => $this->id, 'user_id' => $userId, 'device_id' => $did, 'device_hash' => $fp, 'ip_address' => $ip, 'country' => $geo['country'] ?? null, 'country_name' => $geo['country_name'] ?? null, 'user_agent' => substr((string) $request->userAgent(), 0, 512), 'viewed_at' => now(), ]); \DB::table('playlists')->where('id', $this->id)->increment('view_count'); } /** * Compute previous/next videos from an already-loaded ordered collection * (the playlist's videos in position order). Saves the 4+ extra queries * that getNextVideo() / getPreviousVideo() would each fire. */ public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array { $idx = $orderedVideos->search(fn ($v) => $v->id === $current->id); if ($idx === false) { return [null, $orderedVideos->first()]; } return [ $idx > 0 ? $orderedVideos[$idx - 1] : null, $idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null, ]; } // Check if user owns this playlist public function isOwnedBy($user) { if (! $user) { return false; } return $this->user_id === $user->id; } // Check if video is in this playlist public function hasVideo(Video $video) { return $this->videos()->where('video_id', $video->id)->exists(); } // Get video position in playlist public function getVideoPosition(Video $video) { $pivot = $this->videos()->where('video_id', $video->id)->first(); return $pivot ? $pivot->pivot->position : null; } // Get next video in playlist public function getNextVideo(Video $currentVideo) { $currentPosition = $this->getVideoPosition($currentVideo); if ($currentPosition === null) { return $this->videos()->first(); } return $this->videos() ->wherePivot('position', '>', $currentPosition) ->orderBy('position') ->first(); } // Get previous video in playlist public function getPreviousVideo(Video $currentVideo) { $currentPosition = $this->getVideoPosition($currentVideo); if ($currentPosition === null) { return $this->videos()->last(); } return $this->videos() ->wherePivot('position', '<', $currentPosition) ->orderByDesc('position') ->first(); } // All share URLs use the unguessable token route public function getShareUrlAttribute() { return route('playlists.showByToken', $this->share_token); } // Scope for public playlists public function scopePublic($query) { return $query->where('visibility', 'public'); } // Scope for user's playlists public function scopeForUser($query, $userId) { return $query->where('user_id', $userId); } // Scope for default playlists (Watch Later) public function scopeDefault($query) { return $query->where('is_default', true); } // Visibility helpers public function isPublic() { return $this->visibility === 'public'; } public function isPrivate() { return $this->visibility === 'private'; } public function isUnlisted() { return $this->visibility === 'unlisted'; } // Check if user can view this playlist via the ID route public function canView($user = null) { // Owner can always view if ($user && $this->user_id === $user->id) { return true; } // Only public playlists are accessible via the ID route return $this->visibility === 'public'; } // Check if user can view this playlist via the share-token route public function canViewViaToken($user = null) { if ($user && $this->user_id === $user->id) { return true; } return in_array($this->visibility, ['public', 'unlisted']); } // Check if user can edit this playlist public function canEdit($user = null) { return $user && $this->user_id === $user->id; } // Update video positions after reordering public function reorderVideos($videoIds) { foreach ($videoIds as $index => $videoId) { $this->videos()->updateExistingPivot($videoId, ['position' => $index]); } } // Add video to playlist public function addVideo(Video $video) { if ($this->hasVideo($video)) { return false; } $maxPosition = $this->videos()->max('position') ?? -1; $this->videos()->attach($video->id, [ 'position' => $maxPosition + 1, 'added_at' => now(), ]); return true; } // Remove video from playlist public function removeVideo(Video $video) { if (! $this->hasVideo($video)) { return false; } $this->videos()->detach($video->id); // Reorder remaining videos $this->reorderPositions(); return true; } // Reorder positions after removal protected function reorderPositions() { $videos = $this->videos()->orderBy('position')->get(); $position = 0; foreach ($videos as $video) { $this->videos()->updateExistingPivot($video->id, ['position' => $position]); $position++; } } // Update watch progress for a video in playlist public function updateWatchProgress(Video $video, $seconds) { $pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot; if ($pivot) { $pivot->watched_seconds = $seconds; $pivot->last_watched_at = now(); // Mark as watched if 90% complete if ($video->duration && $seconds >= ($video->duration * 0.9)) { $pivot->watched = true; } $pivot->save(); } } // Get watch progress for a video in playlist public function getWatchProgress(Video $video) { $pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot; return $pivot ? [ 'watched_seconds' => $pivot->watched_seconds, 'watched' => $pivot->watched, 'last_watched_at' => $pivot->last_watched_at, ] : null; } // Get first unwatched video public function getFirstUnwatchedVideo() { return $this->videos() ->wherePivot('watched', false) ->orderBy('position') ->first(); } // Get random video (for shuffle) public function getRandomVideo() { return $this->videos()->inRandomOrder()->first(); } // Static method to create default "Watch Later" playlist public static function createWatchLater($userId) { return self::create([ 'user_id' => $userId, 'name' => 'Watch Later', 'description' => 'Save videos to watch later', 'visibility' => 'private', 'is_default' => true, ]); } // Get or create watch later playlist for user public static function getWatchLater($userId) { $playlist = self::where('user_id', $userId) ->where('is_default', true) ->first(); if (! $playlist) { $playlist = self::createWatchLater($userId); } return $playlist; } }