From 7d8cdd2be8dac03302f74104f31087791d553d43 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Tue, 14 Jul 2026 14:11:50 -0300 Subject: [PATCH] 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. --- .../MangaIngestReviewController.java | 20 +++ .../dto/ManualMangaIngestRequestDTO.java | 21 +++ .../service/MangaIngestReviewService.java | 138 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/main/java/com/magamochi/catalog/model/dto/ManualMangaIngestRequestDTO.java diff --git a/src/main/java/com/magamochi/catalog/controller/MangaIngestReviewController.java b/src/main/java/com/magamochi/catalog/controller/MangaIngestReviewController.java index 8806884..ef74e25 100644 --- a/src/main/java/com/magamochi/catalog/controller/MangaIngestReviewController.java +++ b/src/main/java/com/magamochi/catalog/controller/MangaIngestReviewController.java @@ -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 resolveMangaIngestReviewManually( + @RequestParam Long id, + @RequestPart MultipartFile coverImage, + @RequestPart @Valid ManualMangaIngestRequestDTO request) { + mangaIngestReviewService.resolveIngestReviewManually(id, coverImage, request); + + return DefaultResponseDTO.ok().build(); + } } diff --git a/src/main/java/com/magamochi/catalog/model/dto/ManualMangaIngestRequestDTO.java b/src/main/java/com/magamochi/catalog/model/dto/ManualMangaIngestRequestDTO.java new file mode 100644 index 0000000..3fc8f95 --- /dev/null +++ b/src/main/java/com/magamochi/catalog/model/dto/ManualMangaIngestRequestDTO.java @@ -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 authors, + List genres, + List alternativeTitles) {} diff --git a/src/main/java/com/magamochi/catalog/service/MangaIngestReviewService.java b/src/main/java/com/magamochi/catalog/service/MangaIngestReviewService.java index f4098af..00e9610 100644 --- a/src/main/java/com/magamochi/catalog/service/MangaIngestReviewService.java +++ b/src/main/java/com/magamochi/catalog/service/MangaIngestReviewService.java @@ -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 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)