middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']); } // List user's playlists public function index() { $user = Auth::user(); $playlists = $user->playlists()->orderBy('created_at', 'desc')->get(); return view('playlists.index', compact('playlists')); } // View a single playlist public function show(Playlist $playlist) { // Check if user can view this playlist if (! $playlist->canView(Auth::user())) { abort(404, 'Playlist not found'); } $playlist->loadMissing('user'); $videos = $playlist->videos()->with('user')->orderBy('position')->get(); return view('playlists.show', compact('playlist', 'videos')); } // View playlist via unguessable share token (unlisted playlists) public function showByToken(string $token) { $playlist = Playlist::where('share_token', $token)->firstOrFail(); if (! $playlist->canViewViaToken(Auth::user())) { abort(404, 'Playlist not found'); } $playlist->loadMissing('user'); $videos = $playlist->videos()->with('user')->orderBy('position')->get(); return view('playlists.show', compact('playlist', 'videos')); } // Generate (or reuse) a per-user share tracking token and return the tracking URL public function recordShare(Playlist $playlist) { $userId = Auth::id(); if ($userId) { $existing = DB::table('playlist_shares') ->where('playlist_id', $playlist->id) ->where('user_id', $userId) ->first(); if ($existing) { return response()->json(['url' => route('playlists.accessShare', $existing->token)]); } } do { $token = Str::random(10); } while (DB::table('playlist_shares')->where('token', $token)->exists()); DB::table('playlist_shares')->insert([ 'playlist_id' => $playlist->id, 'user_id' => $userId, 'token' => $token, 'created_at' => now(), ]); return response()->json(['url' => route('playlists.accessShare', $token)]); } // Handle a share link click: record the access, then redirect to the playlist public function accessShare(Request $request, string $token) { $share = DB::table('playlist_shares')->where('token', $token)->first(); if (! $share) { return redirect('/'); } $playlist = Playlist::find($share->playlist_id); if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) { return redirect('/'); } $did = $request->cookie('_did') ?: (string) Str::uuid(); $seen = DB::table('playlist_share_accesses') ->where('share_id', $share->id) ->where('device_id', $did) ->exists(); if (! $seen) { $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $geo = GeoIpService::lookup($ip); DB::table('playlist_share_accesses')->insert([ 'share_id' => $share->id, 'device_id' => $did, 'ip_address' => $ip, 'country' => $geo['country'] ?? null, 'country_name' => $geo['country_name'] ?? null, 'accessed_at' => now(), ]); } // Serve the playlist's own OG metadata to social-media crawlers so previews // show the playlist's picture and name — not the first video's. Humans still // get redirected to the first track for one-tap playback. $ua = (string) $request->userAgent(); $isCrawler = (bool) preg_match( '/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i', $ua ); if ($isCrawler) { $playlist->loadMissing('user'); $videos = $playlist->videos()->with('user')->orderBy('position')->get(); return response() ->view('playlists.show', compact('playlist', 'videos')) ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); } $firstVideo = $playlist->videos()->orderBy('position')->first(); $destination = $firstVideo ? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token : route('playlists.showByToken', $playlist->share_token); return redirect($destination) ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); } // Create new playlist form public function create() { return view('playlists.create'); } // Store new playlist public function store(Request $request) { $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:500', 'visibility' => 'nullable|in:public,private,unlisted', 'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480', ]); $playlistData = [ 'user_id' => Auth::id(), 'name' => $request->name, 'description' => $request->description, 'visibility' => $request->visibility ?? 'private', 'is_default' => false, 'share_token' => Str::random(32), ]; // Create playlist first to get ID for thumbnail naming $playlist = Playlist::create($playlistData); // Handle thumbnail upload if ($request->hasFile('thumbnail')) { $file = $request->file('thumbnail'); $nasPath = self::pushPlaylistThumbnailToNas($file, $playlist); $playlist->update(['thumbnail' => $nasPath]); } // Reload playlist with thumbnail $playlist->refresh(); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'playlist' => [ 'id' => $playlist->id, 'name' => $playlist->name, 'visibility' => $playlist->visibility, 'thumbnail_url' => $playlist->thumbnail_url, ], ]); } return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist created!'); } // Edit playlist form public function edit(Playlist $playlist) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to edit this playlist.'); } if (request()->expectsJson() || request()->ajax()) { return response()->json([ 'success' => true, 'playlist' => [ 'id' => $playlist->id, 'name' => $playlist->name, 'description' => $playlist->description, 'visibility' => $playlist->visibility, ], ]); } return view('playlists.edit', compact('playlist')); } // Update playlist public function update(Request $request, Playlist $playlist) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to edit this playlist.'); } $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:500', 'visibility' => 'nullable|in:public,private,unlisted', 'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480', ]); $updateData = [ 'name' => $request->name, 'description' => $request->description, 'visibility' => $request->visibility ?? 'private', ]; // Handle thumbnail upload if ($request->hasFile('thumbnail')) { // Delete old thumbnail from NAS if exists if ($playlist->thumbnail) { self::deletePlaylistThumbnailFromNas($playlist->thumbnail); } $file = $request->file('thumbnail'); $updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist); } // Handle thumbnail removal if ($request->input('remove_thumbnail') == '1') { if ($playlist->thumbnail) { self::deletePlaylistThumbnailFromNas($playlist->thumbnail); $updateData['thumbnail'] = null; } } $playlist->update($updateData); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Playlist updated!', 'playlist' => [ 'id' => $playlist->id, 'name' => $playlist->name, 'visibility' => $playlist->visibility, ], ]); } return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist updated!'); } // Delete playlist public function destroy(Request $request, Playlist $playlist) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to delete this playlist.'); } // Don't allow deleting default playlists if ($playlist->is_default) { abort(400, 'Cannot delete default playlist.'); } $playlist->delete(); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Playlist deleted!', ]); } return redirect()->route('playlists.index')->with('success', 'Playlist deleted!'); } // Add video to playlist public function addVideo(Request $request, Playlist $playlist) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to edit this playlist.'); } $request->validate([ 'video_id' => 'required|exists:videos,id', ]); $video = Video::findOrFail($request->video_id); // Check if video can be viewed if (! $video->canView(Auth::user())) { abort(403, 'You cannot add this video to your playlist.'); } $added = $playlist->addVideo($video); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => $added ? 'Video added to playlist!' : 'Video is already in playlist.', 'video_count' => $playlist->video_count, ]); } return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.'); } // Remove video from playlist public function removeVideo(Request $request, Playlist $playlist, Video $video) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to edit this playlist.'); } $removed = $playlist->removeVideo($video); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Video removed from playlist.', 'video_count' => $playlist->video_count, ]); } return back()->with('success', 'Video removed from playlist.'); } // Reorder videos in playlist public function reorder(Request $request, Playlist $playlist) { // Check ownership if (! $playlist->canEdit(Auth::user())) { abort(403, 'You do not have permission to edit this playlist.'); } $request->validate([ 'video_ids' => 'required|array', 'video_ids.*' => 'integer|exists:videos,id', ]); $playlist->reorderVideos($request->video_ids); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => 'Playlist reordered!', ]); } return back()->with('success', 'Playlist reordered!'); } // Get user's playlists (for dropdown) public function userPlaylists() { // Handle unauthenticated users if (! Auth::check()) { return response()->json([ 'success' => true, 'playlists' => [], 'authenticated' => false, ]); } $user = Auth::user(); $playlists = $user->playlists()->orderBy('name')->get(); // Get video IDs for each playlist $playlistsWithVideoIds = $playlists->map(function ($p) { return [ 'id' => $p->id, 'name' => $p->name, 'description' => $p->description, 'video_count' => $p->videos()->count(), 'formatted_duration' => $p->formatted_duration, 'is_default' => $p->is_default, 'visibility' => $p->visibility, 'thumbnail_url' => $p->thumbnail_url, 'video_ids' => $p->videos()->pluck('videos.id')->toArray(), ]; }); return response()->json([ 'success' => true, 'playlists' => $playlistsWithVideoIds, 'authenticated' => true, ]); } // Quick add to Watch Later public function watchLater(Request $request, Video $video) { $watchLater = Playlist::getWatchLater(Auth::id()); $added = $watchLater->addVideo($video); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'success' => true, 'message' => $added ? 'Added to Watch Later!' : 'Already in Watch Later.', ]); } return back()->with('success', $added ? 'Added to Watch Later!' : 'Already in Watch Later.'); } // Update watch progress public function updateProgress(Request $request, Playlist $playlist, Video $video) { $request->validate([ 'seconds' => 'required|integer|min:0', ]); $playlist->updateWatchProgress($video, $request->seconds); return response()->json([ 'success' => true, ]); } // Play all videos in playlist (redirect to first video with playlist context) public function playAll(Playlist $playlist) { if (! $playlist->canViewViaToken(Auth::user())) { abort(404, 'Playlist not found'); } $firstVideo = $playlist->videos()->orderBy('position')->first(); if (! $firstVideo) { return back()->with('error', 'Playlist is empty.'); } return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token); } // Shuffle play - redirect to random video public function shuffle(Playlist $playlist) { if (! $playlist->canViewViaToken(Auth::user())) { abort(404, 'Playlist not found'); } $randomVideo = $playlist->getRandomVideo(); if (! $randomVideo) { return back()->with('error', 'Playlist is empty.'); } return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token); } // ── NAS thumbnail helpers ───────────────────────────────────────────────── private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string { $nas = app(\App\Services\NasSyncService::class); $playlist->loadMissing('user'); $userSlug = $nas->userSlug($playlist->user); return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}"; } private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string { $nas = app(\App\Services\NasSyncService::class); $ext = $file->getClientOriginalExtension() ?: 'jpg'; $tmpName = self::generateFilename($ext); $file->storeAs('public/thumbnails', $tmpName); $tempAbs = storage_path('app/public/thumbnails/' . $tmpName); $nasPath = self::nasPlaylistThumbPath($playlist, $ext); $dir = dirname($nasPath); $nas->mkdirp($dir); $nas->putFile($tempAbs, $nasPath); @unlink($tempAbs); return $nasPath; } private static function deletePlaylistThumbnailFromNas(?string $nasPath): void { if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return; try { app(\App\Services\NasSyncService::class)->deleteFile($nasPath); } catch (\Throwable) {} } // Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG // (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card. public function ogImage(Playlist $playlist, NasSyncService $nas) { if (! $playlist->canViewViaToken(Auth::user())) { abort(404); } if ($playlist->thumbnail) { $local = storage_path('app/' . $playlist->thumbnail); if (! file_exists($local)) { @mkdir(dirname($local), 0755, true); $nas->ensureLocalAsset($local, $playlist->thumbnail); } if (file_exists($local)) { $ext = strtolower(pathinfo($local, PATHINFO_EXTENSION)); $src = match ($ext) { 'png' => @imagecreatefrompng($local), 'webp' => @imagecreatefromwebp($local), 'gif' => @imagecreatefromgif($local), default => @imagecreatefromjpeg($local), }; if ($src) { $ow = imagesx($src); $oh = imagesy($src); // Always output an exact 1200x630 canvas (cover-crop, no letterbox) // so the served image matches the og:image:width/height we declare — // a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail. $cw = 1200; $ch = 630; $dst = imagecreatetruecolor($cw, $ch); // Cover: scale so the image fills the whole canvas, center-crop overflow $scale = max($cw / $ow, $ch / $oh); $sw = (int) round($cw / $scale); $sh = (int) round($ch / $scale); $sx = (int) round(($ow - $sw) / 2); $sy = (int) round(($oh - $sh) / 2); imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh); imagedestroy($src); ob_start(); imagejpeg($dst, null, 82); imagedestroy($dst); $jpeg = ob_get_clean(); return response($jpeg, 200, [ 'Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=86400', ]); } } } // Branded fallback card $w = 1200; $h = 630; $img = imagecreatetruecolor($w, $h); $cBg = imagecolorallocate($img, 10, 10, 10); $cRed = imagecolorallocate($img, 230, 30, 30); $cWhite = imagecolorallocate($img, 240, 240, 240); $cGray = imagecolorallocate($img, 120, 120, 120); imagefill($img, 0, 0, $cBg); imagefilledrectangle($img, 0, 0, $w, 8, $cRed); imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed); $cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72; imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed); $tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy]; imagefilledpolygon($img, $tri, $cWhite); $fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'; $fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name'))); $title = $playlist->name ?: 'Playlist'; $maxChars = 42; $lines = []; if (mb_strlen($title) > $maxChars) { $words = explode(' ', $title); $line = ''; foreach ($words as $word) { if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; } else { $line .= ($line ? ' ' : '') . $word; } } if ($line) $lines[] = trim($line); } else { $lines = [$title]; } $lines = array_slice($lines, 0, 2); $titleY = $cy + $r + 60; foreach ($lines as $i => $line) { $bbox = imagettfbbox(28, 0, $fontBold, $line); $tw = $bbox[2] - $bbox[0]; $tx = (int)(($w - $tw) / 2); imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line); } $meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : ''); $bbox = imagettfbbox(16, 0, $fontNormal, $meta); $tw = $bbox[2] - $bbox[0]; $tx = (int)(($w - $tw) / 2); imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta); ob_start(); imagejpeg($img, null, 85); imagedestroy($img); $jpeg = ob_get_clean(); return response($jpeg, 200, [ 'Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=86400', ]); } }