feat: add manga search functionality and proxy endpoints for AniList and MyAnimeList
This commit is contained in:
parent
284f40c279
commit
fc22532886
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user