feat/image-exclusion-list #57

Merged
rov merged 2 commits from feat/image-exclusion-list into main 2026-05-08 11:37:03 -03:00
12 changed files with 189 additions and 14 deletions
Showing only changes of commit 0d9cf7c2a2 - Show all commits

View File

@ -0,0 +1,11 @@
package com.magamochi.common.exception;
public class ExcludedImageException extends RuntimeException {
public ExcludedImageException(String message) {
super(message);
}
public ExcludedImageException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -6,22 +6,30 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
public record MangaContentImagesDTO(
@NotNull Long id,
@NotBlank String mangaTitle,
Long previousContentId,
Long nextContentId,
@NotNull List<@NotBlank String> contentImageKeys) {
public static MangaContentImagesDTO from(MangaContent mangaContent, Long prevId, Long nextId) {
@NotNull List<MangaContentImageDTO> contentImages) {
public static MangaContentImagesDTO from(
MangaContent mangaContent, List<MangaContentImage> images, Long prevId, Long nextId) {
return new MangaContentImagesDTO(
mangaContent.getId(),
mangaContent.getTitle(),
prevId,
nextId,
mangaContent.getMangaContentImages().stream()
images.stream()
.sorted(Comparator.comparing(MangaContentImage::getPosition))
.map(mangaContentImage -> mangaContentImage.getImage().getObjectKey())
.map(
mangaContentImage ->
new MangaContentImageDTO(
mangaContentImage.getImage().getId(),
mangaContentImage.getImage().getObjectKey()))
.toList());
}
public record MangaContentImageDTO(UUID uuid, String objectKey) {}
}

View File

@ -4,6 +4,7 @@ import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.content.model.dto.MangaContentArchiveDTO;
import com.magamochi.content.model.entity.MangaContentImage;
import com.magamochi.content.model.enumeration.ContentArchiveFileType;
import com.magamochi.image.service.ImageExclusionService;
import com.magamochi.image.service.ImageService;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
@ -22,11 +23,12 @@ import org.springframework.stereotype.Service;
public class ContentDownloadService {
private final ContentService contentService;
private final ImageService imageService;
private final ImageExclusionService imageExclusionService;
public MangaContentArchiveDTO downloadContent(
Long mangaContentId, ContentArchiveFileType contentArchiveFileType) throws IOException {
var chapter = contentService.find(mangaContentId);
var chapterImages = chapter.getMangaContentImages();
var chapterImages = imageExclusionService.filterExcludedImages(chapter.getMangaContentImages());
var byteArrayOutputStream =
switch (contentArchiveFileType) {

View File

@ -7,6 +7,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank;
import com.magamochi.catalog.model.specification.MangaImportJobSpecification;
import com.magamochi.catalog.service.MangaContentProviderService;
import com.magamochi.catalog.service.MangaResolutionService;
import com.magamochi.common.exception.ExcludedImageException;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.common.model.enumeration.ContentType;
@ -202,15 +203,25 @@ public class ContentImportService {
for (var sortedEntry : entryMap.entrySet()) {
var bytes = sortedEntry.getValue();
var imageId = imageFetchService.uploadImage(bytes, null, ContentType.CONTENT_IMAGE);
var image = imageService.find(imageId);
try {
var imageId = imageFetchService.uploadImage(bytes, null, ContentType.CONTENT_IMAGE);
var image = imageService.find(imageId);
mangaContentImageRepository.save(
MangaContentImage.builder()
.image(image)
.mangaContent(mangaContent)
.position(position++)
.build());
mangaContentImageRepository.save(
MangaContentImage.builder()
.image(image)
.mangaContent(mangaContent)
.position(position)
.build());
} catch (ExcludedImageException e) {
log.info(
"Image at position {} in content {} is excluded and will be skipped.",
position,
mangaContent.getId());
} finally {
// Ensure we always increment position even if an image is excluded
position++;
}
}
}

View File

@ -6,6 +6,7 @@ import com.magamochi.content.model.dto.MangaContentDTO;
import com.magamochi.content.model.dto.MangaContentImagesDTO;
import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.content.model.repository.MangaContentRepository;
import com.magamochi.image.service.ImageExclusionService;
import com.magamochi.userinteraction.service.UserMangaContentReadService;
import jakarta.validation.constraints.NotNull;
import java.util.Comparator;
@ -19,6 +20,7 @@ import org.springframework.stereotype.Service;
public class ContentService {
private final MangaContentProviderService mangaContentProviderService;
private final UserMangaContentReadService userMangaContentReadService;
private final ImageExclusionService imageExclusionService;
private final MangaContentRepository mangaContentRepository;
@ -70,6 +72,9 @@ public class ContentService {
}
}
return MangaContentImagesDTO.from(mangaContent, prevId, nextId);
var filteredImages =
imageExclusionService.filterExcludedImages(mangaContent.getMangaContentImages());
return MangaContentImagesDTO.from(mangaContent, filteredImages, prevId, nextId);
}
}

View File

@ -0,0 +1,27 @@
package com.magamochi.image.controller;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.image.service.ImageExclusionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/management/image-exclusion")
@RequiredArgsConstructor
@Tag(name = "Image Exclusion", description = "Management API for image exclusion")
public class ImageExclusionController {
private final ImageExclusionService exclusionService;
@Operation(
summary = "Exclude image by UUID",
description =
"Gets the hash of the image with the given UUID and adds it to the exclusion list.")
@PostMapping("/{uuid}")
public DefaultResponseDTO<Void> excludeImage(@PathVariable UUID uuid) {
exclusionService.excludeImageByUuid(uuid);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -0,0 +1,23 @@
package com.magamochi.image.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@Entity
@Table(name = "image_exclusion_list")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class ImageExclusion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String fileHash;
@CreationTimestamp private Instant createdAt;
}

View File

@ -0,0 +1,12 @@
package com.magamochi.image.model.repository;
import com.magamochi.image.model.entity.ImageExclusion;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageExclusionRepository extends JpaRepository<ImageExclusion, Long> {
boolean existsByFileHash(String fileHash);
List<ImageExclusion> findAllByFileHashIn(Collection<String> fileHashes);
}

View File

@ -1,5 +1,6 @@
package com.magamochi.image.queue.consumer;
import com.magamochi.common.exception.ExcludedImageException;
import com.magamochi.common.queue.command.ImageFetchCommand;
import com.magamochi.common.queue.command.ImageUpdateCommand;
import com.magamochi.image.queue.producer.ImageUpdateProducer;
@ -25,6 +26,8 @@ public class ImageFetchConsumer {
imageUpdateProducer.publishImageUpdateCommand(
new ImageUpdateCommand(command.entityId(), imageId), command.contentType());
} catch (ExcludedImageException e) {
log.info("Image at URL {} is excluded from upload, skipping.", command.url());
} catch (Exception e) {
log.error("Failed to fetch image from URL.", e);
}

View File

@ -0,0 +1,58 @@
package com.magamochi.image.service;
import static java.util.Objects.isNull;
import com.magamochi.content.model.entity.MangaContentImage;
import com.magamochi.image.model.entity.ImageExclusion;
import com.magamochi.image.model.repository.ImageExclusionRepository;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Service
@RequiredArgsConstructor
public class ImageExclusionService {
private final ImageService imageService;
private final ImageExclusionRepository exclusionRepository;
@Transactional
public void excludeImageByUuid(UUID imageId) {
var image = imageService.find(imageId);
var hash = image.getFileHash();
if (exclusionRepository.existsByFileHash(hash)) {
log.info("Image hash {} (UUID {}) is already in the exclusion list", hash, imageId);
return;
}
exclusionRepository.save(ImageExclusion.builder().fileHash(hash).build());
log.info("Image hash {} (UUID {}) added to exclusion list", hash, imageId);
}
public Set<String> getExcludedHashes(Collection<String> hashes) {
if (isNull(hashes) || hashes.isEmpty()) {
return Set.of();
}
return exclusionRepository.findAllByFileHashIn(hashes).stream()
.map(ImageExclusion::getFileHash)
.collect(Collectors.toSet());
}
public List<MangaContentImage> filterExcludedImages(List<MangaContentImage> images) {
var hashes = images.stream().map(img -> img.getImage().getFileHash()).toList();
var excludedHashes = getExcludedHashes(hashes);
return images.stream()
.filter(img -> !excludedHashes.contains(img.getImage().getFileHash()))
.toList();
}
}

View File

@ -1,7 +1,9 @@
package com.magamochi.image.service;
import com.magamochi.common.exception.ExcludedImageException;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.image.model.entity.Image;
import com.magamochi.image.model.repository.ImageExclusionRepository;
import com.magamochi.image.model.repository.ImageRepository;
import java.io.InputStream;
import java.util.List;
@ -18,8 +20,15 @@ import org.springframework.stereotype.Service;
public class ImageService {
private final S3Service s3Service;
private final ImageRepository imageRepository;
private final ImageExclusionRepository imageExclusionRepository;
public UUID upload(byte[] data, String contentType, String path, String fileHash) {
var isExcluded = imageExclusionRepository.existsByFileHash(fileHash);
if (isExcluded) {
log.info("Image with hash {} is in the exclusion list, skipping upload", fileHash);
throw new ExcludedImageException("Image with hash " + fileHash + " is excluded from upload");
}
var existingImage = imageRepository.findByFileHash(fileHash);
if (existingImage.isPresent()) {
log.info("Image already exists with hash {}, returning existing ID", fileHash);

View File

@ -0,0 +1,6 @@
CREATE TABLE image_exclusion_list
(
id BIGSERIAL PRIMARY KEY,
file_hash VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);