getSequel($current); if (!$sequel) break; $current = $sequel; // Jikan rate limit: max 3 req/s — small sleep between calls usleep(400_000); // 400ms } return $chain; } /** * Returns the MAL ID of the direct "Sequel" relation, or null. */ public function getSequel(string $malId): ?string { $key = "jikan_relations_{$malId}"; $data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) { $res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}/relations"); if (!$res->ok()) return null; return $res->json(); }); if (!$data || empty($data['data'])) return null; foreach ($data['data'] as $rel) { if (strtolower($rel['relation']) === 'sequel') { foreach ($rel['entry'] as $entry) { if ($entry['type'] === 'anime') { return (string) $entry['mal_id']; } } } } return null; } /** * Search Jikan by title, return best-matching MAL ID or null. * Tries title_jp first, then title_en, then title. * $animeType: 'series'|'movie'|'ova'|'ona'|'special' (optional, improves accuracy) */ public function searchMalId(string $title, ?string $titleEn = null, ?string $titleJp = null, ?string $animeType = null): ?string { $queries = array_values(array_filter(array_unique([$titleJp, $titleEn, $title]))); foreach ($queries as $i => $q) { $malId = $this->searchByQuery($q, $animeType); if ($malId) return $malId; if ($i < count($queries) - 1) usleep(350_000); } return null; } private function searchByQuery(string $query, ?string $animeType = null): ?string { // Map our type → Jikan type; try specific first then fallback to any $jikanType = match ($animeType) { 'movie' => 'movie', 'ova' => 'ova', 'ona' => 'ona', 'special' => 'special', default => 'tv', }; // Try with specific type, then without type restriction (catches edge cases) $typesToTry = array_unique([$jikanType, null]); foreach ($typesToTry as $type) { $cacheKey = 'jikan_s2_' . md5($query . '_' . ($type ?? 'any')); $data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($query, $type) { $params = ['q' => $query, 'limit' => 8, 'sfw' => false]; if ($type) $params['type'] = $type; $res = Http::timeout(10)->get(self::BASE . '/anime', $params); if (!$res->ok()) return null; return $res->json('data'); }); if (!empty($data)) { $best = $this->bestMatch($query, $data); if ($best) return (string) $best['mal_id']; } if ($type !== null) usleep(300_000); // rate limit between type fallback } return null; } /** * Pick the result whose title best matches the query via similar_text. * Falls back to first result if nothing scores > 40%. */ private function bestMatch(string $query, array $results): ?array { $q = mb_strtolower(trim($query)); $best = null; $bestScore = 0; foreach ($results as $item) { $candidates = array_filter([ $item['title'] ?? null, $item['title_english'] ?? null, $item['title_japanese'] ?? null, ]); foreach ($candidates as $t) { similar_text($q, mb_strtolower(trim($t)), $pct); if ($pct > $bestScore) { $bestScore = $pct; $best = $item; } } } // If best match is decent or we have no choice, return it return ($best && $bestScore >= 35) ? $best : ($results[0] ?? null); } /** * Fetch anime details (title_english, title, episodes count etc.) */ public function getAnimeDetails(string $malId): ?array { $key = "jikan_anime_{$malId}"; $data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) { $res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}"); if (!$res->ok()) return null; return $res->json('data'); }); return $data; } }