feat: add manga search functionality and proxy endpoints for AniList and MyAnimeList

This commit is contained in:
Rodrigo Verdiani 2026-04-14 11:17:58 -03:00
parent 284f40c279
commit fc22532886
5 changed files with 219 additions and 84 deletions

View File

@ -28,38 +28,34 @@ public interface JikanClient {
includes = Exception.class)
MangaResponse getMangaById(@PathVariable Long id);
record SearchResponse(List<MangaData> data) {
public record MangaData(Long mal_id, String title, List<TitleData> titles) {
public record TitleData(String type, String title) {}
record SearchResponse(List<MangaData> data) {}
record MangaResponse(MangaData data) {}
public record MangaData(
Long mal_id,
ImageData images,
String title,
List<String> title_synonyms,
String status,
Boolean publishing,
String synopsis,
Float score,
Integer chapters,
PublishData published,
List<AuthorData> authors,
List<GenreData> genres,
List<ExplicitGenreData> explicit_genres) {
public record GenreData(Long mal_id, String name) {}
public record ExplicitGenreData(Long mal_id, String name) {}
public record ImageData(ImageUrls jpg) {
public record ImageUrls(String large_image_url) {}
}
}
record MangaResponse(MangaData data) {
public record MangaData(
Long mal_id,
ImageData images,
String title,
List<String> title_synonyms,
String status,
Boolean publishing,
String synopsis,
Float score,
Integer chapters,
PublishData published,
List<AuthorData> authors,
List<GenreData> genres,
List<ExplicitGenreData> explicit_genres) {
public record ImageData(ImageUrls jpg) {
public record ImageUrls(String large_image_url) {}
}
public record PublishData(OffsetDateTime from, OffsetDateTime to) {}
public record PublishData(OffsetDateTime from, OffsetDateTime to) {}
public record AuthorData(Long mal_id, String name) {}
public record GenreData(Long mal_id, String name) {}
public record ExplicitGenreData(Long mal_id, String name) {}
}
public record AuthorData(Long mal_id, String name) {}
}
}

View File

@ -0,0 +1,77 @@
package com.magamochi.catalog.controller;
import com.magamochi.catalog.model.dto.MangaProxyDataDTO;
import com.magamochi.catalog.service.AniListService;
import com.magamochi.catalog.service.MyAnimeListService;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/catalog")
@RequiredArgsConstructor
public class ProxyController {
private final AniListService aniListService;
private final MyAnimeListService myAnimeListService;
@Operation(
summary = "Get manga data from AniList by ID",
description =
"Fetches manga information from AniList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the AniList API.",
tags = {"Catalog Proxy"},
operationId = "getAniListMangaDataById")
@GetMapping("/proxy/anilist/{id}")
public DefaultResponseDTO<MangaProxyDataDTO> getAniListMangaDataById(@PathVariable Long id) {
return DefaultResponseDTO.ok(MangaProxyDataDTO.from(aniListService.getMangaDataById(id), id));
}
@Operation(
summary = "Get manga data from AniList by title",
description =
"Fetches manga information from AniList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the AniList API.",
tags = {"Catalog Proxy"},
operationId = "searchAniListMangaDataByTitle")
@GetMapping("/proxy/anilist/{title}/search")
public DefaultResponseDTO<List<MangaProxyDataDTO>> searchAniListMangaDataByTitle(
@PathVariable String title) {
var results = aniListService.getMangasByTitle(title);
return DefaultResponseDTO.ok(
results.entrySet().stream()
.map(
mangaDataEntry ->
MangaProxyDataDTO.from(mangaDataEntry.getValue(), mangaDataEntry.getKey()))
.toList());
}
@Operation(
summary = "Get manga data from MyAnimeList by ID",
description =
"Fetches manga information from MyAnimeList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the MyAnimeList API.",
tags = {"Catalog Proxy"},
operationId = "getMyAnimeListMangaDataById")
@GetMapping("/proxy/myanimelist/{id}")
public DefaultResponseDTO<MangaProxyDataDTO> getMyAnimeListMangaDataById(@PathVariable Long id) {
return DefaultResponseDTO.ok(
MangaProxyDataDTO.from(myAnimeListService.getMangaDataById(id), id));
}
@Operation(
summary = "Get manga data from MyAnimeList by title",
description =
"Fetches manga information from MyAnimeList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the MyAnimeList API.",
tags = {"Catalog Proxy"},
operationId = "searchMyAnimeListMangaDataByTitle")
@GetMapping("/proxy/myanimelist/{title}/search")
public DefaultResponseDTO<List<MangaProxyDataDTO>> searchMyAnimeListMangaDataByTitle(
@PathVariable String title) {
var results = myAnimeListService.getMangasByTitle(title);
return DefaultResponseDTO.ok(
results.entrySet().stream()
.map(
mangaDataEntry ->
MangaProxyDataDTO.from(mangaDataEntry.getValue(), mangaDataEntry.getKey()))
.toList());
}
}

View File

@ -0,0 +1,24 @@
package com.magamochi.catalog.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
import java.util.List;
public record MangaProxyDataDTO(
String imageUrl,
@NotBlank String title,
@NotNull List<String> alternativeTitles,
@NotNull List<String> authors,
OffsetDateTime publishedAt,
@NotNull Long id) {
public static MangaProxyDataDTO from(MangaDataDTO data, Long id) {
return new MangaProxyDataDTO(
data.coverImageUrl(),
data.title(),
data.alternativeTitles(),
data.authors(),
data.publishedFrom(),
id);
}
}

View File

@ -52,46 +52,63 @@ public class AniListService {
return Map.of();
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
var request = getSearchGraphQLRequest(title);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
return response.data().page().media().stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
public MangaDataDTO getMangaDataById(Long aniListId) {
var request = getGraphQLRequest(aniListId);
aniListRateLimiter.acquire();
var media = aniListClient.getManga(request).data().Media();
var manga = aniListClient.getManga(request).data().Media();
return parseMangaData(manga).get(aniListId);
}
private Map<Long, MangaDataDTO> parseMangaData(AniListClient.Manga manga) {
var authors =
media.staff().edges().stream()
manga.staff().edges().stream()
.filter(edge -> isAuthorRole(edge.role()))
.map(edge -> edge.node().name().full())
.distinct()
.toList();
boolean hasRomajiTitle = nonNull(media.title().romaji());
return MangaDataDTO.builder()
.title(hasRomajiTitle ? media.title().romaji() : media.title().english())
.score(
nonNull(media.averageScore())
? DoubleUtil.round((double) media.averageScore() / 10, 2)
: 0)
.synopsis(media.description())
.chapterCount(media.chapters())
.publishedFrom(convertFuzzyDate(media.startDate()))
.publishedTo(convertFuzzyDate(media.endDate()))
.authors(authors)
.genres(media.genres())
// TODO: improve this
.alternativeTitles(
hasRomajiTitle
? nonNull(media.title().english())
? List.of(media.title().english(), media.title().nativeTitle())
: List.of(media.title().nativeTitle())
: List.of(media.title().nativeTitle()))
.coverImageUrl(
nonNull(media.coverImage().extraLarge())
? media.coverImage().extraLarge()
: media.coverImage().large())
.status(mapStatus(media.status()))
.adult(nonNull(media.isAdult()) ? media.isAdult() : false)
.build();
boolean hasRomajiTitle = nonNull(manga.title().romaji());
return Map.of(
manga.id(),
MangaDataDTO.builder()
.title(hasRomajiTitle ? manga.title().romaji() : manga.title().english())
.score(
nonNull(manga.averageScore())
? DoubleUtil.round((double) manga.averageScore() / 10, 2)
: 0)
.synopsis(manga.description())
.chapterCount(manga.chapters())
.publishedFrom(convertFuzzyDate(manga.startDate()))
.publishedTo(convertFuzzyDate(manga.endDate()))
.authors(authors)
.genres(manga.genres())
// TODO: improve this
.alternativeTitles(
hasRomajiTitle
? nonNull(manga.title().english())
? List.of(manga.title().english(), manga.title().nativeTitle())
: List.of(manga.title().nativeTitle())
: List.of(manga.title().nativeTitle()))
.coverImageUrl(
nonNull(manga.coverImage().extraLarge())
? manga.coverImage().extraLarge()
: manga.coverImage().large())
.status(mapStatus(manga.status()))
.adult(nonNull(manga.isAdult()) ? manga.isAdult() : false)
.build());
}
private static AniListClient.@NonNull GraphQLRequest getGraphQLRequest(Long aniListId) {
@ -144,6 +161,18 @@ public class AniListService {
native
}
status
startDate { year month day }
coverImage { large, extraLarge }
staff {
edges {
role
node {
name {
full
}
}
}
}
}
}
}

View File

@ -34,11 +34,21 @@ public class MyAnimeListService {
return results.stream()
.collect(
Collectors.toMap(
JikanClient.SearchResponse.MangaData::title,
JikanClient.SearchResponse.MangaData::mal_id,
JikanClient.MangaData::title,
JikanClient.MangaData::mal_id,
(existing, second) -> existing));
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
jikanRateLimiter.acquire();
var response = jikanClient.mangaSearch(title).data();
return response.stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
public MangaDataDTO getMangaDataById(Long malId) {
jikanRateLimiter.acquire();
var response = jikanClient.getMangaById(malId);
@ -48,33 +58,32 @@ public class MyAnimeListService {
}
var responseData = response.data();
return parseMangaData(responseData).get(malId);
}
var authors =
responseData.authors().stream()
.map(JikanClient.MangaResponse.MangaData.AuthorData::name)
.toList();
private Map<Long, MangaDataDTO> parseMangaData(JikanClient.MangaData data) {
var authors = data.authors().stream().map(JikanClient.MangaData.AuthorData::name).toList();
var genres =
responseData.genres().stream()
.map(JikanClient.MangaResponse.MangaData.GenreData::name)
.toList();
var genres = data.genres().stream().map(JikanClient.MangaData.GenreData::name).toList();
var alternativeTitles = responseData.title_synonyms();
var alternativeTitles = data.title_synonyms();
return MangaDataDTO.builder()
.title(responseData.title())
.score(nonNull(responseData.score()) ? DoubleUtil.round(responseData.score(), 2) : 0)
.synopsis(responseData.synopsis())
.chapterCount(responseData.chapters())
.publishedFrom(responseData.published().from())
.publishedTo(responseData.published().to())
.authors(authors)
.genres(genres)
.alternativeTitles(alternativeTitles)
.coverImageUrl(responseData.images().jpg().large_image_url())
.status(mapStatus(responseData.status()))
.adult(nonNull(responseData.explicit_genres()) && !responseData.explicit_genres().isEmpty())
.build();
return Map.of(
data.mal_id(),
MangaDataDTO.builder()
.title(data.title())
.score(nonNull(data.score()) ? DoubleUtil.round(data.score(), 2) : 0)
.synopsis(data.synopsis())
.chapterCount(data.chapters())
.publishedFrom(data.published().from())
.publishedTo(data.published().to())
.authors(authors)
.genres(genres)
.alternativeTitles(alternativeTitles)
.coverImageUrl(data.images().jpg().large_image_url())
.status(mapStatus(data.status()))
.adult(nonNull(data.explicit_genres()) && !data.explicit_genres().isEmpty())
.build());
}
private MangaStatus mapStatus(String malStatus) {