163 lines
5.3 KiB
PHP
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;
|
|
}
|
|
}
|