Compare commits

..

4 Commits

Author SHA1 Message Date
Rodrigo Verdiani
d398ac8f06 feat(catalog): implement custom circuit breaker for external APIs
Replace Spring Retry CircuitBreakerRetryPolicy with lightweight custom
implementation using ConcurrentHashMap and AtomicReference<Instant>.
Avoids classpath conflict between spring-retry and spring-aspects
RetryTemplate, and eliminates stacked retries with Feign @Retryable.

Behavior: circuit opens for 30s after first failure, returns
fallback immediately without hitting external APIs. Transitions
through CLOSED -> OPEN -> HALF_OPEN -> CLOSED automatically.

Applied to AniListService and MyAnimeListService for all
searchMangaByTitle, getMangasByTitle, and getMangaDataById calls.
2026-07-14 14:12:01 -03:00
Rodrigo Verdiani
331df77d28 fix(catalog): guard against null external IDs in manga update pipeline
- MangaUpdateService.update(): skip update when both aniListId and malId
  are null, preventing IllegalStateException for manually-created manga
- MangaUpdateService.fetchExternalMangaData(): return null instead of
  throwing when no external IDs available
- MangaResolutionService.createAndNotifyManga(): only enqueue update
  command when at least one external ID exists, avoiding dead-lettered
  queue messages
2026-07-14 14:11:55 -03:00
Rodrigo Verdiani
7d8cdd2be8 feat(catalog): add manual ingest review resolution with image upload
New endpoint POST /catalog/ingest-reviews/manual accepts multipart
with cover image file and JSON metadata. Creates manga directly from
provided data when neither AniList nor MyAnimeList IDs exist.
Requires: title (validated), cover image (mandatory).
Optional: synopsis, status, score, dates, chapter count, adult flag,
authors, genres, alternative titles.
2026-07-14 14:11:50 -03:00
Rodrigo Verdiani
9938d4649b refactor(image): extract shared SHA-256 hash utility
Move computeHash from ImageFetchService into reusable ImageHashUtil
to avoid duplication when other services need image hashing.
2026-07-14 14:11:44 -03:00
10 changed files with 372 additions and 83 deletions

View File

@ -1,12 +1,16 @@
package com.magamochi.catalog.controller;
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
import com.magamochi.catalog.model.dto.ManualMangaIngestRequestDTO;
import com.magamochi.catalog.service.MangaIngestReviewService;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/catalog")
@ -50,4 +54,20 @@ public class MangaIngestReviewController {
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Resolve manga ingest review manually",
description =
"Resolve a manga ingest review by manually providing all manga data and a cover image.",
tags = {"Manga Ingest Review"},
operationId = "resolveMangaIngestReviewManually")
@PostMapping(value = "/ingest-reviews/manual", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public DefaultResponseDTO<Void> resolveMangaIngestReviewManually(
@RequestParam Long id,
@RequestPart MultipartFile coverImage,
@RequestPart @Valid ManualMangaIngestRequestDTO request) {
mangaIngestReviewService.resolveIngestReviewManually(id, coverImage, request);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -0,0 +1,21 @@
package com.magamochi.catalog.model.dto;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import jakarta.validation.constraints.NotBlank;
import java.time.OffsetDateTime;
import java.util.List;
import lombok.Builder;
@Builder
public record ManualMangaIngestRequestDTO(
@NotBlank String title,
String synopsis,
MangaStatus status,
Double score,
OffsetDateTime publishedFrom,
OffsetDateTime publishedTo,
Integer chapterCount,
Boolean adult,
List<String> authors,
List<String> genres,
List<String> alternativeTitles) {}

View File

@ -8,6 +8,7 @@ import com.magamochi.catalog.client.AniListClient;
import com.magamochi.catalog.model.dto.MangaDataDTO;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.catalog.util.DoubleUtil;
import com.magamochi.common.config.ExternalApiCircuitBreaker;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
@ -26,50 +27,77 @@ import org.springframework.stereotype.Service;
public class AniListService {
private final AniListClient aniListClient;
private final RateLimiter aniListRateLimiter;
private final ExternalApiCircuitBreaker circuitBreaker;
public Map<String, AniListClient.Manga> searchMangaByTitle(String title) {
var request = getSearchGraphQLRequest(title);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getSearchGraphQLRequest(title);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
if (nonNull(response) && nonNull(response.data()) && nonNull(response.data().page())) {
return response.data().page().media().stream()
.flatMap(
manga ->
Stream.of(
manga.title().romaji(),
manga.title().english(),
manga.title().nativeTitle())
.filter(Objects::nonNull)
.filter(t -> !t.isBlank())
.map(titleString -> Map.entry(titleString, manga)))
.collect(
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (existingManga, manga) -> existingManga));
}
if (nonNull(response) && nonNull(response.data()) && nonNull(response.data().page())) {
return response.data().page().media().stream()
.flatMap(
manga ->
Stream.of(
manga.title().romaji(),
manga.title().english(),
manga.title().nativeTitle())
.filter(Objects::nonNull)
.filter(t -> !t.isBlank())
.map(titleString -> Map.entry(titleString, manga)))
.collect(
Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(existingManga, manga) -> existingManga));
}
return Map.of();
return Map.of();
},
throwable -> {
log.error("AniList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
var request = getSearchGraphQLRequest(title);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getSearchGraphQLRequest(title);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
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));
return response.data().page().media().stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
},
throwable -> {
log.error("AniList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public MangaDataDTO getMangaDataById(Long aniListId) {
var request = getGraphQLRequest(aniListId);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getGraphQLRequest(aniListId);
aniListRateLimiter.acquire();
var manga = aniListClient.getManga(request).data().Media();
return parseMangaData(manga).get(aniListId);
aniListRateLimiter.acquire();
var manga = aniListClient.getManga(request).data().Media();
return parseMangaData(manga).get(aniListId);
},
throwable -> {
log.error("AniList circuit breaker open for manga ID {}", aniListId);
throw new RuntimeException("AniList API unavailable");
});
}
private Map<Long, MangaDataDTO> parseMangaData(AniListClient.Manga manga) {

View File

@ -1,18 +1,35 @@
package com.magamochi.catalog.service;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
import com.magamochi.catalog.model.dto.ManualMangaIngestRequestDTO;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.entity.MangaAlternativeTitle;
import com.magamochi.catalog.model.entity.MangaAuthor;
import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.catalog.model.entity.MangaGenre;
import com.magamochi.catalog.model.entity.MangaIngestReview;
import com.magamochi.catalog.model.enumeration.MangaState;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.image.model.entity.Image;
import com.magamochi.image.service.ImageService;
import com.magamochi.image.util.ImageHashUtil;
import com.magamochi.ingestion.service.ContentProviderService;
import jakarta.transaction.Transactional;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.tika.Tika;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Log4j2
@Service
@ -20,9 +37,15 @@ import org.springframework.stereotype.Service;
public class MangaIngestReviewService {
private final MangaResolutionService mangaResolutionService;
private final ContentProviderService contentProviderService;
private final ImageService imageService;
private final GenreService genreService;
private final AuthorService authorService;
private final MangaIngestReviewRepository mangaIngestReviewRepository;
private final MangaContentProviderRepository mangaContentProviderRepository;
private final MangaRepository mangaRepository;
private final Tika tika = new Tika();
public List<MangaIngestReviewDTO> get() {
return mangaIngestReviewRepository.findAll().stream().map(MangaIngestReviewDTO::from).toList();
@ -90,6 +113,121 @@ public class MangaIngestReviewService {
contentProviderId);
}
@Transactional
public void resolveIngestReviewManually(
Long reviewId, MultipartFile coverImage, ManualMangaIngestRequestDTO dto) {
if (coverImage.isEmpty()) {
throw new IllegalArgumentException("Cover image is required");
}
var ingestReview = get(reviewId);
var contentProviderId = ingestReview.getContentProvider().getId();
if (mangaContentProviderRepository.existsByMangaTitleIgnoreCaseAndContentProvider_Id(
dto.title(), contentProviderId)) {
log.info(
"Manga with title '{}' already exists for provider '{}', skipping ingest",
dto.title(),
contentProviderId);
return;
}
var contentProvider = contentProviderService.find(contentProviderId);
var image = uploadCoverImage(coverImage);
var unsavedManga = buildManga(dto, image);
var manga = mangaRepository.save(unsavedManga);
if (nonNull(dto.genres())) {
var genres =
dto.genres().stream()
.map(name -> MangaGenre.builder().manga(manga).genre(genreService.findOrCreateGenre(name)).build())
.toList();
manga.setMangaGenres(genres);
}
if (nonNull(dto.authors())) {
var authors =
dto.authors().stream()
.map(
name ->
MangaAuthor.builder().manga(manga).author(authorService.findOrCreateAuthor(name)).build())
.toList();
manga.setMangaAuthors(authors);
}
if (nonNull(dto.alternativeTitles())) {
var titles =
dto.alternativeTitles().stream()
.map(title -> MangaAlternativeTitle.builder().title(title).manga(manga).build())
.toList();
manga.setAlternativeTitles(titles);
}
mangaContentProviderRepository.save(
MangaContentProvider.builder()
.manga(manga)
.mangaTitle(dto.title())
.contentProvider(contentProvider)
.url(ingestReview.getUrl())
.build());
mangaIngestReviewRepository.delete(ingestReview);
log.info(
"Successfully ingested manga '{}' manually from provider {}",
dto.title(),
contentProviderId);
}
private Image uploadCoverImage(MultipartFile coverImage) {
try {
var bytes = coverImage.getBytes();
var contentType = resolveContentType(coverImage.getContentType(), bytes);
var hash = ImageHashUtil.computeSha256(bytes);
var imageId = imageService.upload(bytes, contentType, "cover", hash);
return imageService.find(imageId);
} catch (IOException e) {
throw new RuntimeException("Failed to read cover image bytes", e);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not available", e);
}
}
private String resolveContentType(String headerContentType, byte[] fileBytes) {
if (nonNull(headerContentType) && headerContentType.startsWith("image/")) {
return headerContentType;
}
return tika.detect(fileBytes);
}
private Manga buildManga(ManualMangaIngestRequestDTO dto, Image coverImage) {
var builder = Manga.builder()
.title(dto.title())
.synopsis(dto.synopsis())
.status(dto.status())
.score(dto.score())
.publishedFrom(dto.publishedFrom())
.publishedTo(dto.publishedTo())
.coverImage(coverImage)
.state(MangaState.AVAILABLE)
.mangaAuthors(Collections.emptyList())
.mangaGenres(Collections.emptyList())
.alternativeTitles(Collections.emptyList());
if (nonNull(dto.chapterCount())) {
builder.chapterCount(dto.chapterCount());
}
if (nonNull(dto.adult())) {
builder.adult(dto.adult());
}
return builder.build();
}
private MangaIngestReview get(Long id) {
return mangaIngestReviewRepository
.findById(id)

View File

@ -157,11 +157,13 @@ public class MangaResolutionService {
mangaRepository.save(
Manga.builder().title(title).aniListId(aniListId).malId(malId).build());
try {
mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(manga.getId()));
} catch (Exception e) {
log.error(
"Failed to send manga update command for manga '{}' (ID: {})", title, manga.getId(), e);
if (nonNull(aniListId) || nonNull(malId)) {
try {
mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(manga.getId()));
} catch (Exception e) {
log.error(
"Failed to send manga update command for manga '{}' (ID: {})", title, manga.getId(), e);
}
}
return manga;

View File

@ -1,5 +1,6 @@
package com.magamochi.catalog.service;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.catalog.model.dto.MangaDataDTO;
@ -49,6 +50,12 @@ public class MangaUpdateService {
log.info("Updating manga with ID {}", mangaId);
var manga = mangaService.find(mangaId);
if (isNull(manga.getAniListId()) && isNull(manga.getMalId())) {
log.info("Skipping update for manga {}: no external IDs available", mangaId);
return;
}
var mangaData = fetchExternalMangaData(manga);
applyUpdatesToDatabase(manga, mangaData);
@ -95,8 +102,7 @@ public class MangaUpdateService {
throw (RuntimeException) fetchException;
}
throw new IllegalStateException(
"Cannot update manga: No external provider IDs found for Manga " + manga.getId());
return null;
}
private void applyUpdatesToDatabase(Manga manga, MangaDataDTO mangaData) {

View File

@ -8,6 +8,7 @@ import com.magamochi.catalog.client.JikanClient;
import com.magamochi.catalog.model.dto.MangaDataDTO;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.catalog.util.DoubleUtil;
import com.magamochi.common.config.ExternalApiCircuitBreaker;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@ -20,45 +21,70 @@ import org.springframework.stereotype.Service;
public class MyAnimeListService {
private final JikanClient jikanClient;
private final RateLimiter jikanRateLimiter;
private final ExternalApiCircuitBreaker circuitBreaker;
/// Searches for manga titles on MyAnimeList using the Jikan API and returns a map of title to MAL
// ID.
public Map<String, Long> searchMangaByTitle(String titleToSearch) {
jikanRateLimiter.acquire();
var results = jikanClient.mangaSearch(titleToSearch).data();
if (results.isEmpty()) {
log.warn("No manga found with title {}", titleToSearch);
return Map.of();
}
return circuitBreaker.execute(
"myAnimeList",
() -> {
jikanRateLimiter.acquire();
var results = jikanClient.mangaSearch(titleToSearch).data();
if (results.isEmpty()) {
log.warn("No manga found with title {}", titleToSearch);
return Map.of();
}
return results.stream()
.collect(
Collectors.toMap(
JikanClient.MangaData::title,
JikanClient.MangaData::mal_id,
(existing, second) -> existing));
return results.stream()
.collect(
Collectors.toMap(
JikanClient.MangaData::title,
JikanClient.MangaData::mal_id,
(existing, second) -> existing));
},
throwable -> {
log.error("MyAnimeList circuit breaker open for search '{}'", titleToSearch);
return Map.of();
});
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
jikanRateLimiter.acquire();
var response = jikanClient.mangaSearch(title).data();
return circuitBreaker.execute(
"myAnimeList",
() -> {
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));
return response.stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
},
throwable -> {
log.error("MyAnimeList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public MangaDataDTO getMangaDataById(Long malId) {
jikanRateLimiter.acquire();
var response = jikanClient.getMangaById(malId);
if (isNull(response) || isNull(response.data())) {
log.warn("No manga found with MAL ID {}", malId);
return null;
}
return circuitBreaker.execute(
"myAnimeList",
() -> {
jikanRateLimiter.acquire();
var response = jikanClient.getMangaById(malId);
if (isNull(response) || isNull(response.data())) {
log.warn("No manga found with MAL ID {}", malId);
return null;
}
var responseData = response.data();
return parseMangaData(responseData).get(malId);
var responseData = response.data();
return parseMangaData(responseData).get(malId);
},
throwable -> {
log.error("MyAnimeList circuit breaker open for manga ID {}", malId);
throw new RuntimeException("MyAnimeList API unavailable");
});
}
private Map<Long, MangaDataDTO> parseMangaData(JikanClient.MangaData data) {

View File

@ -0,0 +1,40 @@
package com.magamochi.common.config;
import java.time.Instant;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
@Log4j2
@Component
public class ExternalApiCircuitBreaker {
private static final long OPEN_DURATION_SECONDS = 30;
private final ConcurrentHashMap<String, AtomicReference<Instant>> circuits =
new ConcurrentHashMap<>();
public <T> T execute(String name, Callable<T> callable, Function<Throwable, T> fallback) {
var circuit = circuits.computeIfAbsent(name, k -> new AtomicReference<>(Instant.MIN));
var now = Instant.now();
var openUntil = circuit.get();
if (now.isBefore(openUntil)) {
log.warn("Circuit '{}' OPEN — failing fast", name);
return fallback.apply(null);
}
try {
T result = callable.call();
circuit.set(Instant.MIN);
return result;
} catch (Exception e) {
circuit.set(now.plusSeconds(OPEN_DURATION_SECONDS));
log.error("Circuit '{}' OPEN for {}s: {}", name, OPEN_DURATION_SECONDS, e.getMessage());
return fallback.apply(e);
}
}
}

View File

@ -4,12 +4,12 @@ import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.image.util.ImageHashUtil;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@ -54,7 +54,7 @@ public class ImageFetchService {
throws NoSuchAlgorithmException {
var fileContentType = resolveContentType(httpResponse, imageBytes);
var fileHash = computeHash(imageBytes);
var fileHash = ImageHashUtil.computeSha256(imageBytes);
return imageManagerService.upload(
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
@ -76,22 +76,4 @@ public class ImageFetchService {
return tika.detect(fileBytes);
}
private String computeHash(byte[] content) throws NoSuchAlgorithmException {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(content);
var hexString = new StringBuilder(2 * hashBytes.length);
for (byte b : hashBytes) {
var hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@ -0,0 +1,26 @@
package com.magamochi.image.util;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class ImageHashUtil {
public static String computeSha256(byte[] content) throws NoSuchAlgorithmException {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(content);
var hexString = new StringBuilder(2 * hashBytes.length);
for (byte b : hashBytes) {
var hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}