Files
2026-07-14 00:01:48 +03:00

163 lines
5.3 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class JikanService
{
const BASE = 'https://api.jikan.moe/v4';
const CACHE_TTL = 60 * 60 * 24 * 7; // 1 week
/**
* Given a root MAL ID, walk the sequel chain and return
* an ordered array of MAL IDs: [s1_mal_id, s2_mal_id, ...]
*/
public function fetchSeasonMalIds(string $rootMalId): array
{
$chain = [];
$visited = [];
$current = $rootMalId;
// Walk up to 10 sequels (safety limit)
for ($i = 0; $i < 10; $i++) {
if (in_array($current, $visited)) break;
$visited[] = $current;
$chain[] = $current;
$sequel = $this->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;
}
}