Files
Mangarr/src/Domain/Manga/Infrastructure/Client/MangadexClient.php
ext.jeremy.guillot@maxicoffee.domains 814fe46ce5 feat(manga): implémenter la page Découvrir avec recommandations MangaDex
- Endpoint GET /api/manga-discover via DiscoverMangaStateProvider + DiscoverMangaHandler
- Algorithme : top 5 manga de la collection → appel /manga/{id}/recommendation
  par source → agrégation avec système de votes (multi-sources = plus pertinent)
- Filtrage : tags exclus (Oneshot, Doujinshi, Self-Published), contentRating,
  et suppression des manga déjà en bibliothèque
- Page Vue DiscoverPage.vue : chargement auto au montage, bouton Actualiser,
  modal détail, ajout à la bibliothèque
- Adapteurs InMemory de test mis à jour (discover + getMangaRecommendations)
2026-03-15 21:43:57 +01:00

195 lines
6.6 KiB
PHP

<?php
namespace App\Domain\Manga\Infrastructure\Client;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Exception\MangadexApiException;
use App\Domain\Manga\Domain\Exception\MangadexAuthenticationException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MangadexClient implements MangadexClientInterface
{
private const API_URL = 'https://api.mangadex.org';
private const AUTH_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
private const EXCLUDED_TAGS = ['0234a31e-a729-4e28-9d6a-3f87c4966b9e' , 'b13b2a48-c720-44a9-9c77-39c9979373fb', '891cf039-b895-47f0-9229-bef4c96eccd4'];
private ?string $accessToken = null;
private ?string $refreshToken = null;
public function __construct(
private HttpClientInterface $client,
private string $clientId,
private string $clientSecret,
private string $username,
private string $password
) {
}
public function authenticate(): void
{
try {
$response = $this->client->request('POST', self::AUTH_URL, [
'body' => [
'grant_type' => 'password',
'username' => $this->username,
'password' => $this->password,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]
]);
$data = $response->toArray();
if (!isset($data['access_token'], $data['refresh_token'])) {
throw new MangadexAuthenticationException('Invalid authentication response from Mangadex');
}
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'];
} catch (\Exception $e) {
throw new MangadexAuthenticationException(
'Failed to authenticate with Mangadex: ' . $e->getMessage(),
$e
);
}
}
public function refreshToken(): void
{
if (!$this->refreshToken) {
throw new MangadexAuthenticationException('No refresh token available');
}
try {
$response = $this->client->request('POST', self::AUTH_URL, [
'body' => [
'grant_type' => 'refresh_token',
'refresh_token' => $this->refreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]
]);
$data = $response->toArray();
if (!isset($data['access_token'])) {
throw new MangadexAuthenticationException('Invalid refresh token response from Mangadex');
}
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'] ?? $this->refreshToken;
} catch (\Exception $e) {
throw new MangadexAuthenticationException(
'Failed to refresh token: ' . $e->getMessage(),
$e
);
}
}
public function searchManga(string $title): array
{
return $this->get('/manga', [
'title' => $title,
'contentRating' => ['safe', 'suggestive', 'erotica'],
'excludedTags' => self::EXCLUDED_TAGS,
'includes' => ['cover_art', 'author'],
'limit' => 50,
]);
}
public function getMangaRatings(array $mangaIds): array
{
return $this->get('/statistics/manga', [
'manga' => $mangaIds,
]);
}
public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array
{
return $this->get('/manga/' . $mangaId . '/feed', [
'limit' => $limit,
// 'translatedLanguage' => ['en'],
'includeUnavailable' => 1,
'order' => ['chapter' => $order],
'offset' => $offset,
]);
}
public function getMangaAggregate(string $mangaId): array
{
return $this->get('/manga/' . $mangaId . '/aggregate');
}
public function getManga(string $mangaId): array
{
return $this->get('/manga/' . $mangaId, [
'includes' => ['cover_art', 'author']
]);
}
public function getMangaRecommendations(string $mangaId): array
{
// L'endpoint retourne des objets manga_recommendation avec des relationships
// vers les manga (sans détails). Il faut d'abord récupérer les IDs, puis
// fetcher les manga en batch avec leurs détails complets.
$recommendations = $this->get('/manga/' . $mangaId . '/recommendation');
$recommendedIds = [];
foreach ($recommendations['data'] ?? [] as $item) {
foreach ($item['relationships'] ?? [] as $rel) {
if ($rel['type'] === 'manga' && $rel['id'] !== $mangaId) {
$recommendedIds[] = $rel['id'];
}
}
}
if (empty($recommendedIds)) {
return ['data' => []];
}
return $this->get('/manga', [
'ids' => $recommendedIds,
'includes' => ['cover_art', 'author'],
'contentRating' => ['safe', 'suggestive', 'erotica'],
'excludedTags' => self::EXCLUDED_TAGS,
'limit' => count($recommendedIds),
]);
}
private function get(string $endpoint, array $params = []): array
{
try {
if (!$this->accessToken) {
$this->authenticate();
}
$response = $this->client->request('GET', self::API_URL . $endpoint, [
'query' => $params,
'headers' => [
'Authorization' => 'Bearer ' . $this->accessToken
]
]);
// Handle 401 (Unauthorized) by refreshing the token and retrying
if ($response->getStatusCode() === 401) {
$this->refreshToken();
$response = $this->client->request('GET', self::API_URL . $endpoint, [
'query' => $params,
'headers' => [
'Authorization' => 'Bearer ' . $this->accessToken
]
]);
}
return $response->toArray();
} catch (MangadexAuthenticationException $e) {
throw $e;
} catch (\Exception $e) {
throw new MangadexApiException(
sprintf('Failed to fetch data from Mangadex: %s', $e->getMessage()),
$e
);
}
}
}