From 814fe46ce5a98616a86e3fab72d4ddad9eeb3d49 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Sun, 15 Mar 2026 21:43:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(manga):=20impl=C3=A9menter=20la=20page=20D?= =?UTF-8?q?=C3=A9couvrir=20avec=20recommandations=20MangaDex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../manga/application/store/mangaStore.js | 26 ++- .../infrastructure/api/apiMangaRepository.js | 11 + .../manga/presentation/pages/DiscoverPage.vue | 192 ++++++++++++++++++ assets/vue/app/router/index.js | 4 +- .../Manga/Application/Query/DiscoverManga.php | 7 + .../QueryHandler/DiscoverMangaHandler.php | 69 +++++++ .../Client/MangadexClientInterface.php | 20 ++ .../Provider/MangaProviderInterface.php | 5 + .../Resource/MangaDiscoverResource.php | 22 ++ .../Provider/DiscoverMangaStateProvider.php | 41 ++++ .../Infrastructure/Client/MangadexClient.php | 29 +++ .../Provider/MangadexProvider.php | 49 +++++ .../Manga/Adapter/InMemoryMangaProvider.php | 5 + .../Manga/Adapter/InMemoryMangadexClient.php | 5 + 14 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue create mode 100644 src/Domain/Manga/Application/Query/DiscoverManga.php create mode 100644 src/Domain/Manga/Application/QueryHandler/DiscoverMangaHandler.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaDiscoverResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DiscoverMangaStateProvider.php diff --git a/assets/vue/app/domain/manga/application/store/mangaStore.js b/assets/vue/app/domain/manga/application/store/mangaStore.js index 156c78a..c81ddcb 100644 --- a/assets/vue/app/domain/manga/application/store/mangaStore.js +++ b/assets/vue/app/domain/manga/application/store/mangaStore.js @@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', { // --- Add Manga State --- addingManga: false, - addMangaError: null + addMangaError: null, + + // --- Discover State --- + discoverResults: [], + loadingDiscover: false, + discoverError: null }), getters: { @@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', { this.loadingSearch = false; }, + // --- Discover Actions --- + async loadDiscoverRecommendations() { + if (this.loadingDiscover) return; + + this.loadingDiscover = true; + this.discoverError = null; + this.discoverResults = []; + + try { + const data = await mangaRepository.discoverManga(); + this.discoverResults = data.items || []; + } catch (error) { + this.discoverError = error.message; + throw error; + } finally { + this.loadingDiscover = false; + } + }, + // --- Add Manga Actions --- async createFromMangaDex(externalId) { if (this.addingManga) return; diff --git a/assets/vue/app/domain/manga/infrastructure/api/apiMangaRepository.js b/assets/vue/app/domain/manga/infrastructure/api/apiMangaRepository.js index 81e5243..5af644b 100644 --- a/assets/vue/app/domain/manga/infrastructure/api/apiMangaRepository.js +++ b/assets/vue/app/domain/manga/infrastructure/api/apiMangaRepository.js @@ -104,6 +104,17 @@ export class ApiMangaRepository { } } + async discoverManga() { + try { + const response = await fetch('/api/manga-discover'); + if (!response.ok) throw new Error('Failed to fetch discover recommendations'); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + async createFromMangaDex(externalId) { try { const response = await fetch('/api/mangas/create-from-mangadex', { diff --git a/assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue b/assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue new file mode 100644 index 0000000..4d23936 --- /dev/null +++ b/assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue @@ -0,0 +1,192 @@ + + + diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index 4531c54..ae30316 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -3,6 +3,7 @@ import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue'; import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue'; import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; +import DiscoverPage from '../domain/manga/presentation/pages/DiscoverPage.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue'; @@ -74,8 +75,7 @@ const routes = [ { path: '/manga/discover', name: 'discover', - component: PlaceholderComponent, - props: { title: 'Découvrir' } + component: DiscoverPage }, { path: '/convert', diff --git a/src/Domain/Manga/Application/Query/DiscoverManga.php b/src/Domain/Manga/Application/Query/DiscoverManga.php new file mode 100644 index 0000000..9432cb2 --- /dev/null +++ b/src/Domain/Manga/Application/Query/DiscoverManga.php @@ -0,0 +1,7 @@ +mangaRepository->findAll(page: 1, limit: 1000); + + $ownedExternalIds = []; + $mangasWithRating = []; + foreach ($localMangas as $manga) { + if (!$manga->getExternalId()) { + continue; + } + $ownedExternalIds[] = $manga->getExternalId()->getValue(); + $mangasWithRating[] = $manga; + } + + usort($mangasWithRating, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0)); + $sourceIds = array_map( + fn (Manga $m) => $m->getExternalId()->getValue(), + array_slice($mangasWithRating, 0, 5) + ); + + $collection = $this->mangaProvider->discover($sourceIds); + + $recommendations = array_values(array_filter( + $collection->getItems(), + fn (Manga $m) => $m->getExternalId() === null + || !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true) + )); + + return new MangaSearchResponse( + array_map( + fn (Manga $manga, int $index) => new MangaSearchItem( + id: $index, + externalId: $manga->getExternalId()->getValue(), + title: $manga->getTitle()->getValue(), + slug: $manga->getSlug()->getValue(), + description: $manga->getDescription(), + author: $manga->getAuthor(), + publicationYear: $manga->getPublicationYear(), + genres: $manga->getGenres(), + status: $manga->getStatus(), + imageUrl: $manga->getImageUrl(), + thumbnailUrl: $manga->getImageUrls()?->getThumbnail(), + rating: $manga->getRating() + ), + $recommendations, + array_keys($recommendations) + ) + ); + } +} diff --git a/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php b/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php index acd9696..cbf736b 100644 --- a/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php +++ b/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php @@ -93,4 +93,24 @@ interface MangadexClientInterface * } */ public function getManga(string $mangaId): array; + + /** + * @return array{ + * data: array, + * description: array, + * year: ?int, + * status: string, + * tags: array}}> + * }, + * relationships: array + * }> + * } + */ + public function getMangaRecommendations(string $mangaId): array; } diff --git a/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php b/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php index 937769b..e8dec95 100644 --- a/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php +++ b/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php @@ -11,4 +11,9 @@ interface MangaProviderInterface public function search(string $title): MangaCollection; public function findByExternalId(ExternalId $externalId): ?Manga; + + /** + * @param string[] $sourceExternalIds IDs MangaDex des manga sources + */ + public function discover(array $sourceExternalIds): MangaCollection; } diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaDiscoverResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaDiscoverResource.php new file mode 100644 index 0000000..9217610 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaDiscoverResource.php @@ -0,0 +1,22 @@ +handler->handle(new DiscoverManga()); + + return new MangaSearchCollection( + items: array_map( + fn ($item) => new MangaSearchItem( + externalId: $item->externalId, + title: $item->title, + slug: $item->slug, + description: $item->description, + author: $item->author, + publicationYear: $item->publicationYear, + genres: $item->genres, + status: $item->status, + imageUrl: $item->imageUrl, + thumbnailUrl: $item->thumbnailUrl, + rating: $item->rating + ), + $response->items + ) + ); + } +} diff --git a/src/Domain/Manga/Infrastructure/Client/MangadexClient.php b/src/Domain/Manga/Infrastructure/Client/MangadexClient.php index e66ba6c..7e437e6 100644 --- a/src/Domain/Manga/Infrastructure/Client/MangadexClient.php +++ b/src/Domain/Manga/Infrastructure/Client/MangadexClient.php @@ -127,6 +127,35 @@ class MangadexClient implements MangadexClientInterface ]); } + 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 { diff --git a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php index 859dcdf..860a121 100644 --- a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php +++ b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php @@ -135,6 +135,55 @@ readonly class MangadexProvider implements MangaProviderInterface } } + public function discover(array $sourceExternalIds): MangaCollection + { + if (empty($sourceExternalIds)) { + return new MangaCollection([]); + } + + // Compter les votes : un manga recommandé par plusieurs sources est plus pertinent. + // On conserve aussi la position d'apparition pour départager les ex-aequo. + $votes = []; + $firstPosition = []; + $resultsById = []; + $position = 0; + + foreach ($sourceExternalIds as $externalId) { + try { + $response = $this->client->getMangaRecommendations($externalId); + foreach ($response['data'] ?? [] as $result) { + $id = $result['id']; + $votes[$id] = ($votes[$id] ?? 0) + 1; + if (!isset($firstPosition[$id])) { + $firstPosition[$id] = $position++; + $resultsById[$id] = $result; + } + } + } catch (\Exception) { + continue; + } + } + + if (empty($resultsById)) { + return new MangaCollection([]); + } + + // Trier : votes décroissants (multi-sources = plus pertinent), puis position croissante (score API) + uksort($resultsById, function (string $a, string $b) use ($votes, $firstPosition): int { + $voteDiff = $votes[$b] - $votes[$a]; + if ($voteDiff !== 0) { + return $voteDiff; + } + + return $firstPosition[$a] <=> $firstPosition[$b]; + }); + + $mangas = $this->createMangasFromResults(array_values($resultsById)); + $this->enrichWithRatings($mangas); + + return new MangaCollection($mangas); + } + public function findByExternalId(ExternalId $externalId): ?Manga { try { diff --git a/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php b/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php index 8325234..2c5dc73 100644 --- a/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php +++ b/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php @@ -43,4 +43,9 @@ class InMemoryMangaProvider implements MangaProviderInterface return null; } + + public function discover(array $sourceExternalIds): MangaCollection + { + return new MangaCollection([]); + } } \ No newline at end of file diff --git a/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php b/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php index 1996aea..505ce9b 100644 --- a/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php +++ b/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php @@ -106,6 +106,11 @@ class InMemoryMangadexClient implements MangadexClientInterface ]; } + public function getMangaRecommendations(string $mangaId): array + { + return ['data' => []]; + } + public function addManga(string $id, array $data): void { $this->mangas[$id] = $data; -- 2.49.1