Compare commits
4 Commits
cc4c8105ef
...
d398ac8f06
| Author | SHA1 | Date | |
|---|---|---|---|
| d398ac8f06 | |||
| 331df77d28 | |||
| 7d8cdd2be8 | |||
| 9938d4649b |
@ -1,12 +1,16 @@
|
|||||||
package com.magamochi.catalog.controller;
|
package com.magamochi.catalog.controller;
|
||||||
|
|
||||||
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
|
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.ManualMangaIngestRequestDTO;
|
||||||
import com.magamochi.catalog.service.MangaIngestReviewService;
|
import com.magamochi.catalog.service.MangaIngestReviewService;
|
||||||
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/catalog")
|
@RequestMapping("/catalog")
|
||||||
@ -50,4 +54,20 @@ public class MangaIngestReviewController {
|
|||||||
|
|
||||||
return DefaultResponseDTO.ok().build();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {}
|
||||||
@ -8,6 +8,7 @@ import com.magamochi.catalog.client.AniListClient;
|
|||||||
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
import com.magamochi.catalog.util.DoubleUtil;
|
import com.magamochi.catalog.util.DoubleUtil;
|
||||||
|
import com.magamochi.common.config.ExternalApiCircuitBreaker;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -26,8 +27,12 @@ import org.springframework.stereotype.Service;
|
|||||||
public class AniListService {
|
public class AniListService {
|
||||||
private final AniListClient aniListClient;
|
private final AniListClient aniListClient;
|
||||||
private final RateLimiter aniListRateLimiter;
|
private final RateLimiter aniListRateLimiter;
|
||||||
|
private final ExternalApiCircuitBreaker circuitBreaker;
|
||||||
|
|
||||||
public Map<String, AniListClient.Manga> searchMangaByTitle(String title) {
|
public Map<String, AniListClient.Manga> searchMangaByTitle(String title) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"aniList",
|
||||||
|
() -> {
|
||||||
var request = getSearchGraphQLRequest(title);
|
var request = getSearchGraphQLRequest(title);
|
||||||
|
|
||||||
aniListRateLimiter.acquire();
|
aniListRateLimiter.acquire();
|
||||||
@ -46,13 +51,23 @@ public class AniListService {
|
|||||||
.map(titleString -> Map.entry(titleString, manga)))
|
.map(titleString -> Map.entry(titleString, manga)))
|
||||||
.collect(
|
.collect(
|
||||||
Collectors.toMap(
|
Collectors.toMap(
|
||||||
Map.Entry::getKey, Map.Entry::getValue, (existingManga, manga) -> existingManga));
|
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) {
|
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"aniList",
|
||||||
|
() -> {
|
||||||
var request = getSearchGraphQLRequest(title);
|
var request = getSearchGraphQLRequest(title);
|
||||||
|
|
||||||
aniListRateLimiter.acquire();
|
aniListRateLimiter.acquire();
|
||||||
@ -62,14 +77,27 @@ public class AniListService {
|
|||||||
.map(this::parseMangaData)
|
.map(this::parseMangaData)
|
||||||
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
|
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
|
||||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
.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) {
|
public MangaDataDTO getMangaDataById(Long aniListId) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"aniList",
|
||||||
|
() -> {
|
||||||
var request = getGraphQLRequest(aniListId);
|
var request = getGraphQLRequest(aniListId);
|
||||||
|
|
||||||
aniListRateLimiter.acquire();
|
aniListRateLimiter.acquire();
|
||||||
var manga = aniListClient.getManga(request).data().Media();
|
var manga = aniListClient.getManga(request).data().Media();
|
||||||
return parseMangaData(manga).get(aniListId);
|
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) {
|
private Map<Long, MangaDataDTO> parseMangaData(AniListClient.Manga manga) {
|
||||||
|
|||||||
@ -1,18 +1,35 @@
|
|||||||
package com.magamochi.catalog.service;
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
import static java.util.Objects.isNull;
|
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.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.MangaContentProvider;
|
||||||
|
import com.magamochi.catalog.model.entity.MangaGenre;
|
||||||
import com.magamochi.catalog.model.entity.MangaIngestReview;
|
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.MangaContentProviderRepository;
|
||||||
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
|
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaRepository;
|
||||||
import com.magamochi.common.exception.NotFoundException;
|
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 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 java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.apache.tika.Tika;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Log4j2
|
@Log4j2
|
||||||
@Service
|
@Service
|
||||||
@ -20,9 +37,15 @@ import org.springframework.stereotype.Service;
|
|||||||
public class MangaIngestReviewService {
|
public class MangaIngestReviewService {
|
||||||
private final MangaResolutionService mangaResolutionService;
|
private final MangaResolutionService mangaResolutionService;
|
||||||
private final ContentProviderService contentProviderService;
|
private final ContentProviderService contentProviderService;
|
||||||
|
private final ImageService imageService;
|
||||||
|
private final GenreService genreService;
|
||||||
|
private final AuthorService authorService;
|
||||||
|
|
||||||
private final MangaIngestReviewRepository mangaIngestReviewRepository;
|
private final MangaIngestReviewRepository mangaIngestReviewRepository;
|
||||||
private final MangaContentProviderRepository mangaContentProviderRepository;
|
private final MangaContentProviderRepository mangaContentProviderRepository;
|
||||||
|
private final MangaRepository mangaRepository;
|
||||||
|
|
||||||
|
private final Tika tika = new Tika();
|
||||||
|
|
||||||
public List<MangaIngestReviewDTO> get() {
|
public List<MangaIngestReviewDTO> get() {
|
||||||
return mangaIngestReviewRepository.findAll().stream().map(MangaIngestReviewDTO::from).toList();
|
return mangaIngestReviewRepository.findAll().stream().map(MangaIngestReviewDTO::from).toList();
|
||||||
@ -90,6 +113,121 @@ public class MangaIngestReviewService {
|
|||||||
contentProviderId);
|
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) {
|
private MangaIngestReview get(Long id) {
|
||||||
return mangaIngestReviewRepository
|
return mangaIngestReviewRepository
|
||||||
.findById(id)
|
.findById(id)
|
||||||
|
|||||||
@ -157,12 +157,14 @@ public class MangaResolutionService {
|
|||||||
mangaRepository.save(
|
mangaRepository.save(
|
||||||
Manga.builder().title(title).aniListId(aniListId).malId(malId).build());
|
Manga.builder().title(title).aniListId(aniListId).malId(malId).build());
|
||||||
|
|
||||||
|
if (nonNull(aniListId) || nonNull(malId)) {
|
||||||
try {
|
try {
|
||||||
mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(manga.getId()));
|
mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(manga.getId()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error(
|
log.error(
|
||||||
"Failed to send manga update command for manga '{}' (ID: {})", title, manga.getId(), e);
|
"Failed to send manga update command for manga '{}' (ID: {})", title, manga.getId(), e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return manga;
|
return manga;
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.magamochi.catalog.service;
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
import static java.util.Objects.nonNull;
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
@ -49,6 +50,12 @@ public class MangaUpdateService {
|
|||||||
log.info("Updating manga with ID {}", mangaId);
|
log.info("Updating manga with ID {}", mangaId);
|
||||||
|
|
||||||
var manga = mangaService.find(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);
|
var mangaData = fetchExternalMangaData(manga);
|
||||||
applyUpdatesToDatabase(manga, mangaData);
|
applyUpdatesToDatabase(manga, mangaData);
|
||||||
|
|
||||||
@ -95,8 +102,7 @@ public class MangaUpdateService {
|
|||||||
throw (RuntimeException) fetchException;
|
throw (RuntimeException) fetchException;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IllegalStateException(
|
return null;
|
||||||
"Cannot update manga: No external provider IDs found for Manga " + manga.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyUpdatesToDatabase(Manga manga, MangaDataDTO mangaData) {
|
private void applyUpdatesToDatabase(Manga manga, MangaDataDTO mangaData) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import com.magamochi.catalog.client.JikanClient;
|
|||||||
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
import com.magamochi.catalog.util.DoubleUtil;
|
import com.magamochi.catalog.util.DoubleUtil;
|
||||||
|
import com.magamochi.common.config.ExternalApiCircuitBreaker;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -20,10 +21,14 @@ import org.springframework.stereotype.Service;
|
|||||||
public class MyAnimeListService {
|
public class MyAnimeListService {
|
||||||
private final JikanClient jikanClient;
|
private final JikanClient jikanClient;
|
||||||
private final RateLimiter jikanRateLimiter;
|
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
|
/// Searches for manga titles on MyAnimeList using the Jikan API and returns a map of title to MAL
|
||||||
// ID.
|
// ID.
|
||||||
public Map<String, Long> searchMangaByTitle(String titleToSearch) {
|
public Map<String, Long> searchMangaByTitle(String titleToSearch) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"myAnimeList",
|
||||||
|
() -> {
|
||||||
jikanRateLimiter.acquire();
|
jikanRateLimiter.acquire();
|
||||||
var results = jikanClient.mangaSearch(titleToSearch).data();
|
var results = jikanClient.mangaSearch(titleToSearch).data();
|
||||||
if (results.isEmpty()) {
|
if (results.isEmpty()) {
|
||||||
@ -37,9 +42,17 @@ public class MyAnimeListService {
|
|||||||
JikanClient.MangaData::title,
|
JikanClient.MangaData::title,
|
||||||
JikanClient.MangaData::mal_id,
|
JikanClient.MangaData::mal_id,
|
||||||
(existing, second) -> existing));
|
(existing, second) -> existing));
|
||||||
|
},
|
||||||
|
throwable -> {
|
||||||
|
log.error("MyAnimeList circuit breaker open for search '{}'", titleToSearch);
|
||||||
|
return Map.of();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
|
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"myAnimeList",
|
||||||
|
() -> {
|
||||||
jikanRateLimiter.acquire();
|
jikanRateLimiter.acquire();
|
||||||
var response = jikanClient.mangaSearch(title).data();
|
var response = jikanClient.mangaSearch(title).data();
|
||||||
|
|
||||||
@ -47,9 +60,17 @@ public class MyAnimeListService {
|
|||||||
.map(this::parseMangaData)
|
.map(this::parseMangaData)
|
||||||
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
|
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
|
||||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
.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) {
|
public MangaDataDTO getMangaDataById(Long malId) {
|
||||||
|
return circuitBreaker.execute(
|
||||||
|
"myAnimeList",
|
||||||
|
() -> {
|
||||||
jikanRateLimiter.acquire();
|
jikanRateLimiter.acquire();
|
||||||
var response = jikanClient.getMangaById(malId);
|
var response = jikanClient.getMangaById(malId);
|
||||||
if (isNull(response) || isNull(response.data())) {
|
if (isNull(response) || isNull(response.data())) {
|
||||||
@ -59,6 +80,11 @@ public class MyAnimeListService {
|
|||||||
|
|
||||||
var responseData = response.data();
|
var responseData = response.data();
|
||||||
return parseMangaData(responseData).get(malId);
|
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) {
|
private Map<Long, MangaDataDTO> parseMangaData(JikanClient.MangaData data) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,12 +4,12 @@ import static java.util.Objects.nonNull;
|
|||||||
|
|
||||||
import com.google.common.util.concurrent.RateLimiter;
|
import com.google.common.util.concurrent.RateLimiter;
|
||||||
import com.magamochi.common.model.enumeration.ContentType;
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import com.magamochi.image.util.ImageHashUtil;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -54,7 +54,7 @@ public class ImageFetchService {
|
|||||||
throws NoSuchAlgorithmException {
|
throws NoSuchAlgorithmException {
|
||||||
var fileContentType = resolveContentType(httpResponse, imageBytes);
|
var fileContentType = resolveContentType(httpResponse, imageBytes);
|
||||||
|
|
||||||
var fileHash = computeHash(imageBytes);
|
var fileHash = ImageHashUtil.computeSha256(imageBytes);
|
||||||
|
|
||||||
return imageManagerService.upload(
|
return imageManagerService.upload(
|
||||||
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
|
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
|
||||||
@ -76,22 +76,4 @@ public class ImageFetchService {
|
|||||||
|
|
||||||
return tika.detect(fileBytes);
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/main/java/com/magamochi/image/util/ImageHashUtil.java
Normal file
26
src/main/java/com/magamochi/image/util/ImageHashUtil.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user