feat(MangaCreationService): add AniList ID support for manga retrieval and creation

This commit is contained in:
Rodrigo Verdiani 2025-12-31 19:25:47 -03:00
parent b736178709
commit befb66be5b
13 changed files with 385 additions and 113 deletions

View File

@ -0,0 +1,51 @@
package com.magamochi.mangamochi.client;
import io.github.resilience4j.retry.annotation.Retry;
import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@FeignClient(name = "aniList", url = "https://graphql.anilist.co")
@Retry(name = "AniListRetry")
public interface AniListClient {
@PostMapping
MangaResponse getManga(@RequestBody GraphQLRequest request);
record GraphQLRequest(String query, Variables variables) {
public record Variables(Long id) {}
}
record MangaResponse(Data data) {
public record Data(Manga Media) {}
public record Manga(
Long id,
Long idMal,
Title title,
String status,
String description, // synopsis
int chapters,
int averageScore, // score (0-100)
CoverImage coverImage,
List<String> genres,
FuzzyDate startDate,
FuzzyDate endDate,
StaffConnection staff) {
public record Title(String romaji, String english, String nativeTitle) {}
public record CoverImage(String large) {}
public record FuzzyDate(Integer year, Integer month, Integer day) {}
public record StaffConnection(List<StaffEdge> edges) {
public record StaffEdge(String role, Staff node) {
public record Staff(Name name) {
public record Name(String full) {}
}
}
}
}
}
}

View File

@ -2,7 +2,9 @@ package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.client.NtfyClient;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.mangamochi.model.repository.UserRepository;
import com.magamochi.mangamochi.queue.UpdateMangaDataProducer;
import com.magamochi.mangamochi.task.ImageCleanupTask;
import com.magamochi.mangamochi.task.MangaFollowUpdateTask;
import com.magamochi.mangamochi.task.UpdateMangaListTask;
@ -18,8 +20,20 @@ public class ManagementController {
private final ImageCleanupTask imageCleanupTask;
private final MangaFollowUpdateTask mangaFollowUpdateTask;
private final UserRepository userRepository;
private final NtfyClient ntfyClient;
private final UpdateMangaDataProducer updateMangaDataProducer;
@Operation(
summary = "Trigger manga data update",
description = "Triggers the update of the metadata for a manga by its ID",
tags = {"Management"},
operationId = "triggerUpdateMangaData")
@PostMapping("update-manga-data/{mangaId}")
public DefaultResponseDTO<Void> triggerUpdateMangaData(@PathVariable Long mangaId) {
updateMangaDataProducer.sendUpdateMangaDataCommand(new UpdateMangaDataCommand(mangaId));
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Queue update manga list",

View File

@ -2,4 +2,4 @@ package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotNull;
public record ImportRequestDTO(String metadataId, @NotNull String id) {}
public record ImportRequestDTO(String metadataId, String aniListId, @NotNull String id) {}

View File

@ -22,6 +22,8 @@ public class Manga {
private Long malId;
private Long aniListId;
private String title;
private String status;

View File

@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByMalId(Long aLong);
Optional<Author> findByName(String name);
}

View File

@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface GenreRepository extends JpaRepository<Genre, Long> {
Optional<Genre> findByMalId(Long malId);
Optional<Genre> findByName(String name);
}

View File

@ -13,4 +13,6 @@ public interface MangaRepository
List<Manga> findByFollowTrue();
Optional<Manga> findByMalId(Long malId);
Optional<Manga> findByAniListId(Long aniListId);
}

View File

@ -1,6 +1,9 @@
package com.magamochi.mangamochi.service;
import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.AniListClient;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.model.dto.TitleMatchRequestDTO;
import com.magamochi.mangamochi.model.dto.UpdateMangaDataCommand;
@ -24,6 +27,7 @@ public class MangaCreationService {
private final TitleMatcherService titleMatcherService;
private final JikanClient jikanClient;
private final AniListClient aniListClient;
private final RateLimiter jikanRateLimiter;
@ -78,22 +82,68 @@ public class MangaCreationService {
}
var result = resultOptional.get();
return getOrCreateManga(result.mal_id(), result.title());
return getOrCreateManga(result.mal_id(), null, result.title());
}
public Manga getOrCreateManga(Long malId) {
public Manga getOrCreateManga(Long malId, Long aniListId) {
if (nonNull(malId)) {
try {
jikanRateLimiter.acquire();
var data = jikanClient.getMangaById(malId);
return getOrCreateManga(data.data().mal_id(), data.data().title());
return getOrCreateManga(data.data().mal_id(), aniListId, data.data().title());
} catch (feign.FeignException.NotFound e) {
log.warn("Manga not found on MyAnimeList for ID: {}", malId);
}
}
private Manga getOrCreateManga(Long malId, String title) {
return mangaRepository
.findByMalId(malId)
.orElseGet(
if (nonNull(aniListId)) {
try {
var query =
"""
query ($id: Int) {
Media (id: $id, type: MANGA) {
id
idMal
title {
romaji
english
native
}
}
}
""";
var request =
new AniListClient.GraphQLRequest(
query, new AniListClient.GraphQLRequest.Variables(aniListId));
var data = aniListClient.getManga(request).data().Media();
String title =
nonNull(data.title().english()) ? data.title().english() : data.title().romaji();
return getOrCreateManga(data.idMal(), data.id(), title);
} catch (feign.FeignException.NotFound e) {
log.warn("Manga not found on AniList for ID: {}", aniListId);
}
}
throw new RuntimeException("Could not find manga on any provider");
}
private Manga getOrCreateManga(Long malId, Long aniListId, String title) {
var mangaOptional = java.util.Optional.<Manga>empty();
if (nonNull(malId)) {
mangaOptional = mangaRepository.findByMalId(malId);
}
if (mangaOptional.isEmpty() && nonNull(aniListId)) {
mangaOptional = mangaRepository.findByAniListId(aniListId);
}
return mangaOptional.orElseGet(
() -> {
var manga = mangaRepository.save(Manga.builder().title(title).malId(malId).build());
var manga =
mangaRepository.save(
Manga.builder().title(title).malId(malId).aniListId(aniListId).build());
updateMangaDataProducer.sendUpdateMangaDataCommand(
new UpdateMangaDataCommand(manga.getId()));

View File

@ -4,6 +4,7 @@ import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.AniListClient;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
@ -12,7 +13,10 @@ import com.magamochi.mangamochi.model.repository.*;
import com.magamochi.mangamochi.util.DoubleUtil;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@ -43,6 +47,7 @@ public class MangaImportService {
private final MangaRepository mangaRepository;
private final JikanClient jikanClient;
private final AniListClient aniListClient;
private final MangaChapterImageRepository mangaChapterImageRepository;
private final MangaAlternativeTitlesRepository mangaAlternativeTitlesRepository;
@ -133,7 +138,35 @@ public class MangaImportService {
public void updateMangaData(Manga manga) {
log.info("Updating manga {}", manga.getTitle());
if (nonNull(manga.getMalId())) {
try {
updateFromJikan(manga);
return;
} catch (Exception e) {
log.warn(
"Error updating manga data from Jikan for manga {}. Trying AniList... Error: {}",
manga.getTitle(),
e.getMessage());
}
}
if (nonNull(manga.getAniListId())) {
try {
updateFromAniList(manga);
return;
} catch (Exception e) {
log.warn(
"Error updating manga data from AniList for manga {}. Error: {}",
manga.getTitle(),
e.getMessage());
}
}
log.warn(
"Could not update manga data for {}. No provider data available/found.", manga.getTitle());
}
private void updateFromJikan(Manga manga) throws IOException, URISyntaxException {
jikanRateLimiter.acquire();
var mangaData = jikanClient.getMangaById(manga.getMalId());
@ -159,19 +192,7 @@ public class MangaImportService {
.build())))
.toList();
var mangaAuthors =
authors.stream()
.map(
author ->
mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder().manga(manga).author(author).build())))
.toList();
manga.setMangaAuthors(mangaAuthors);
updateMangaAuthors(manga, authors);
var genres =
mangaData.data().genres().stream()
@ -188,6 +209,137 @@ public class MangaImportService {
.build())))
.toList();
updateMangaGenres(manga, genres);
if (isNull(manga.getCoverImage())) {
downloadCoverImage(manga, mangaData.data().images().jpg().large_image_url());
}
var mangaEntity = mangaRepository.save(manga);
var alternativeTitles =
mangaData.data().title_synonyms().stream()
.map(at -> MangaAlternativeTitle.builder().manga(mangaEntity).title(at).build())
.toList();
mangaAlternativeTitlesRepository.saveAll(alternativeTitles);
}
private void updateFromAniList(Manga manga) throws IOException, URISyntaxException {
var query =
"""
query ($id: Int) {
Media (id: $id, type: MANGA) {
startDate { year month day }
endDate { year month day }
description
status
averageScore
chapters
coverImage { large }
genres
staff {
edges {
role
node {
name {
full
}
}
}
}
}
}
""";
var request =
new AniListClient.GraphQLRequest(
query, new AniListClient.GraphQLRequest.Variables(manga.getAniListId()));
var media = aniListClient.getManga(request).data().Media();
manga.setSynopsis(media.description());
manga.setStatus(mapAniListStatus(media.status()));
manga.setScore(DoubleUtil.round((double) media.averageScore() / 10, 2)); // 0-100 -> 0-10
manga.setPublishedFrom(convertFuzzyDate(media.startDate()));
manga.setPublishedTo(convertFuzzyDate(media.endDate()));
manga.setChapterCount(media.chapters());
var authors =
media.staff().edges().stream()
.filter(edge -> isAuthorRole(edge.role()))
.map(edge -> edge.node().name().full())
.distinct()
.map(
name ->
authorRepository
.findByName(name)
.orElseGet(
() -> authorRepository.save(Author.builder().name(name).build())))
.toList();
updateMangaAuthors(manga, authors);
var genres =
media.genres().stream()
.map(
name ->
genreRepository
.findByName(name)
.orElseGet(() -> genreRepository.save(Genre.builder().name(name).build())))
.toList();
updateMangaGenres(manga, genres);
if (isNull(manga.getCoverImage())) {
downloadCoverImage(manga, media.coverImage().large());
}
mangaRepository.save(manga);
}
private boolean isAuthorRole(String role) {
return role.equalsIgnoreCase("Story & Art")
|| role.equalsIgnoreCase("Story")
|| role.equalsIgnoreCase("Art");
}
private String mapAniListStatus(String status) {
return switch (status) {
case "RELEASING" -> "Publishing";
case "FINISHED" -> "Finished";
case "NOT_YET_RELEASED" -> "Not yet published";
default -> "Unknown";
};
}
private OffsetDateTime convertFuzzyDate(AniListClient.MangaResponse.Manga.FuzzyDate date) {
if (isNull(date) || isNull(date.year())) {
return null;
}
return OffsetDateTime.of(
date.year(),
isNull(date.month()) ? 1 : date.month(),
isNull(date.day()) ? 1 : date.day(),
0,
0,
0,
0,
ZoneOffset.UTC);
}
private void updateMangaAuthors(Manga manga, List<Author> authors) {
var mangaAuthors =
authors.stream()
.map(
author ->
mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder().manga(manga).author(author).build())))
.toList();
manga.setMangaAuthors(mangaAuthors);
}
private void updateMangaGenres(Manga manga, List<Genre> genres) {
var mangaGenres =
genres.stream()
.map(
@ -199,14 +351,13 @@ public class MangaImportService {
mangaGenreRepository.save(
MangaGenre.builder().manga(manga).genre(genre).build())))
.toList();
manga.setMangaGenres(mangaGenres);
}
if (isNull(manga.getCoverImage())) {
private void downloadCoverImage(Manga manga, String imageUrl)
throws IOException, URISyntaxException {
var inputStream =
new BufferedInputStream(
new URL(new URI(mangaData.data().images().jpg().large_image_url()).toASCIIString())
.openStream());
new BufferedInputStream(new URL(new URI(imageUrl).toASCIIString()).openStream());
var bytes = inputStream.readAllBytes();
@ -216,18 +367,6 @@ public class MangaImportService {
manga.setCoverImage(image);
}
var mangaEntity = mangaRepository.save(manga);
var alternativeTitles =
mangaData.data().title_synonyms().stream()
.map(at -> MangaAlternativeTitle.builder().manga(mangaEntity).title(at).build())
.toList();
mangaAlternativeTitlesRepository.saveAll(alternativeTitles);
} catch (Exception e) {
log.warn("Error updating manga data for manga {}. {}", manga.getTitle(), e);
}
}
public MangaChapter persistMangaChapter(
MangaProvider mangaProvider, ContentProviderMangaChapterResponseDTO chapter) {
var mangaChapter =

View File

@ -31,9 +31,12 @@ public class ProviderManualMangaImportService {
var title = contentProvider.getMangaTitle(requestDTO.id());
var malId = nonNull(requestDTO.metadataId()) ? Long.parseLong(requestDTO.metadataId()) : null;
var aniListId = nonNull(requestDTO.aniListId()) ? Long.parseLong(requestDTO.aniListId()) : null;
var manga =
(nonNull(requestDTO.metadataId()) && !requestDTO.metadataId().isBlank())
? mangaCreationService.getOrCreateManga(Long.parseLong(requestDTO.metadataId()))
nonNull(malId) || nonNull(aniListId)
? mangaCreationService.getOrCreateManga(malId, aniListId)
: mangaCreationService.getOrCreateManga(title, requestDTO.id(), provider);
if (isNull(manga)) {

View File

@ -35,7 +35,7 @@ public class MangaDexProvider implements ContentProvider, ManualImportContentPro
List.of("safe", "suggestive", "erotica", "pornographic"));
var mangas = new ArrayList<>(response.data());
var totalPages = (int) Math.ceil((double) response.total() / 500);
var totalPages = (int) Math.ceil((double) response.total() / 100);
try {
IntStream.range(1, totalPages)
@ -47,8 +47,8 @@ public class MangaDexProvider implements ContentProvider, ManualImportContentPro
var pagedResponse =
mangaDexClient.getMangaFeed(
UUID.fromString(provider.getUrl()),
500,
i * 500,
100,
i * 100,
List.of("safe", "suggestive", "erotica", "pornographic"));
mangas.addAll(pagedResponse.data());

View File

@ -74,6 +74,12 @@ resilience4j:
seconds: 5
retry-exceptions:
- feign.FeignException
AniListRetry:
max-attempts: 5
wait-duration:
seconds: 5
retry-exceptions:
- feign.FeignException
ImageDownloadRetry:
max-attempts: 3
wait-duration:

View File

@ -0,0 +1 @@
ALTER TABLE mangas ADD COLUMN ani_list_id BIGINT;