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.
This commit is contained in:
Rodrigo Verdiani 2026-07-14 14:11:50 -03:00
parent 9938d4649b
commit 7d8cdd2be8
3 changed files with 179 additions and 0 deletions

View File

@ -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();
}
} }

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

@ -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)