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:
parent
9938d4649b
commit
7d8cdd2be8
@ -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) {}
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user