withHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json']) ->post(self::ENDPOINT, ['query' => $gql, 'variables' => $variables]); if (!$res->ok()) return null; if (!empty($res->json('errors'))) return null; return $res->json('data.Media'); } catch (\Throwable $e) { Log::debug('AniList query failed', ['err' => $e->getMessage()]); return null; } } public function fetchByMalId(int $malId): ?array { return Cache::remember("anilist_mal_{$malId}", self::CACHE_TTL, function () use ($malId) { return $this->query( ['malId' => $malId], 'query($malId:Int){Media(idMal:$malId,type:ANIME){coverImage{extraLarge}bannerImage}}' ); }); } public function fetchByTitle(string $title): ?array { return Cache::remember('anilist_title_' . md5($title), self::CACHE_TTL, function () use ($title) { return $this->query( ['search' => $title], 'query($search:String){Media(search:$search,type:ANIME){coverImage{extraLarge}bannerImage}}' ); }); } /** * Resmi indir, yeniden boyutlandır, WebP olarak storage'a kaydet. * Başarılıysa storage-relative yolu döner (örn. anime/covers/123.webp). */ /** * Resmi indir, max genişliğe orantılı küçült (asla büyütme), WebP kaydet. * Orijinalden küçükse olduğu gibi bırakır. */ private function downloadAndResize(string $url, string $storagePath, int $maxW): ?string { try { $response = Http::timeout(20)->withHeaders([ 'User-Agent' => 'Mozilla/5.0', 'Referer' => 'https://anilist.co/', ])->get($url); if (!$response->ok()) return null; $raw = $response->body(); $src = @imagecreatefromstring($raw); if (!$src) return null; $srcW = imagesx($src); $srcH = imagesy($src); if ($srcW > $maxW) { // Orantılı küçült $newW = $maxW; $newH = (int) round($srcH * ($maxW / $srcW)); $dst = imagecreatetruecolor($newW, $newH); imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH); imagedestroy($src); } else { // Zaten küçük — olduğu gibi kullan $dst = $src; } $absPath = storage_path('app/public/' . $storagePath); @mkdir(dirname($absPath), 0755, true); $ok = imagewebp($dst, $absPath, self::WEBP_QUALITY); imagedestroy($dst); return $ok ? $storagePath : null; } catch (\Throwable $e) { Log::debug('AniList image download failed', ['url' => $url, 'err' => $e->getMessage()]); return null; } } /** * Anime'nin boş kapak/banner alanlarını AniList'ten doldur. * Resimleri indirir, boyutlandırır, WebP olarak storage'a kaydeder. * Dolu alanların üzerine yazmaz. */ public function fillImages(Anime $anime): bool { $needCover = empty($anime->cover_image); $needBanner = empty($anime->banner_image); if (!$needCover && !$needBanner) return false; $data = null; if ($anime->mal_id) { $data = $this->fetchByMalId((int) $anime->mal_id); } if (!$data) { $data = $this->fetchByTitle($anime->title); } if (!$data) return false; $updates = []; if ($needCover && !empty($data['coverImage']['extraLarge'])) { $path = $this->downloadAndResize( $data['coverImage']['extraLarge'], "anime/covers/{$anime->id}.webp", self::COVER_MAX_W ); if ($path) $updates['cover_image'] = $path; } if ($needBanner && !empty($data['bannerImage'])) { $path = $this->downloadAndResize( $data['bannerImage'], "anime/banners/{$anime->id}.webp", self::BANNER_MAX_W ); if ($path) $updates['banner_image'] = $path; } if (empty($updates)) return false; $anime->update($updates); return true; } }