From 189769b8484799edf892cac8951d64b11ce3a9ca Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Fri, 8 May 2026 11:24:36 -0300 Subject: [PATCH] feat: implement scheduled cleanup for excluded images --- .../model/repository/MangaRepository.java | 3 + .../MangaContentImageRepository.java | 8 +- .../controller/ManagementController.java | 14 +++ .../image/service/ImageExclusionService.java | 85 +++++++++++++++++++ .../image/task/ExclusionCleanupTask.java | 28 ++++++ src/main/resources/application.yml | 2 + 6 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/magamochi/image/task/ExclusionCleanupTask.java diff --git a/src/main/java/com/magamochi/catalog/model/repository/MangaRepository.java b/src/main/java/com/magamochi/catalog/model/repository/MangaRepository.java index f423f81..1d43a30 100644 --- a/src/main/java/com/magamochi/catalog/model/repository/MangaRepository.java +++ b/src/main/java/com/magamochi/catalog/model/repository/MangaRepository.java @@ -3,6 +3,7 @@ package com.magamochi.catalog.model.repository; import com.magamochi.catalog.model.entity.Manga; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -15,4 +16,6 @@ public interface MangaRepository Optional findByMalId(Long malId); Optional findByAniListId(Long aniListId); + + List findAllByCoverImage_Id(UUID imageId); } diff --git a/src/main/java/com/magamochi/content/model/repository/MangaContentImageRepository.java b/src/main/java/com/magamochi/content/model/repository/MangaContentImageRepository.java index 5ba885f..cdaad29 100644 --- a/src/main/java/com/magamochi/content/model/repository/MangaContentImageRepository.java +++ b/src/main/java/com/magamochi/content/model/repository/MangaContentImageRepository.java @@ -1,12 +1,14 @@ package com.magamochi.content.model.repository; -import com.magamochi.content.model.entity.MangaContent; import com.magamochi.content.model.entity.MangaContentImage; import java.util.List; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface MangaContentImageRepository extends JpaRepository { - List findAllByMangaContent(MangaContent mangaContent); - boolean existsByMangaContent_IdAndPosition(Long mangaContentId, int position); + + List findAllByImage_Id(UUID imageId); + + List findAllByMangaContent_IdOrderByPositionAsc(Long mangaContentId); } diff --git a/src/main/java/com/magamochi/controller/ManagementController.java b/src/main/java/com/magamochi/controller/ManagementController.java index c6cdb35..d7b4d80 100644 --- a/src/main/java/com/magamochi/controller/ManagementController.java +++ b/src/main/java/com/magamochi/controller/ManagementController.java @@ -2,6 +2,7 @@ package com.magamochi.controller; import com.magamochi.client.NtfyClient; import com.magamochi.common.model.dto.DefaultResponseDTO; +import com.magamochi.image.service.ImageExclusionService; import com.magamochi.image.task.ImageCleanupTask; import com.magamochi.user.repository.UserRepository; import com.magamochi.userinteraction.task.MangaFollowUpdateTask; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class ManagementController { private final ImageCleanupTask imageCleanupTask; + private final ImageExclusionService imageExclusionService; private final MangaFollowUpdateTask mangaFollowUpdateTask; private final UserRepository userRepository; private final NtfyClient ntfyClient; @@ -30,6 +32,18 @@ public class ManagementController { return DefaultResponseDTO.ok().build(); } + @Operation( + summary = "Cleanup excluded images", + description = "Triggers the cleanup of images that are in the exclusion list", + tags = {"Management"}, + operationId = "imageExclusionCleanup") + @PostMapping("image-exclusion-cleanup") + public DefaultResponseDTO imageExclusionCleanup() { + imageExclusionService.cleanupExcludedImages(); + + return DefaultResponseDTO.ok().build(); + } + @Operation( summary = "Trigger user follow update", description = "Trigger user follow update", diff --git a/src/main/java/com/magamochi/image/service/ImageExclusionService.java b/src/main/java/com/magamochi/image/service/ImageExclusionService.java index 97dc636..96da6f8 100644 --- a/src/main/java/com/magamochi/image/service/ImageExclusionService.java +++ b/src/main/java/com/magamochi/image/service/ImageExclusionService.java @@ -2,17 +2,22 @@ package com.magamochi.image.service; import static java.util.Objects.isNull; +import com.magamochi.catalog.model.repository.MangaRepository; import com.magamochi.content.model.entity.MangaContentImage; +import com.magamochi.content.model.repository.MangaContentImageRepository; import com.magamochi.image.model.entity.ImageExclusion; import com.magamochi.image.model.repository.ImageExclusionRepository; +import com.magamochi.image.model.repository.ImageRepository; import java.util.Collection; import java.util.List; +import java.util.Optional; 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.Propagation; import org.springframework.transaction.annotation.Transactional; @Log4j2 @@ -20,8 +25,12 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class ImageExclusionService { private final ImageService imageService; + private final S3Service s3Service; private final ImageExclusionRepository exclusionRepository; + private final ImageRepository imageRepository; + private final MangaRepository mangaRepository; + private final MangaContentImageRepository mangaContentImageRepository; @Transactional public void excludeImageByUuid(UUID imageId) { @@ -55,4 +64,80 @@ public class ImageExclusionService { .filter(img -> !excludedHashes.contains(img.getImage().getFileHash())) .toList(); } + + @Transactional + public void cleanupExcludedImages() { + log.info("Starting cleanup of excluded images..."); + + var exclusions = exclusionRepository.findAll(); + log.info("Found {} excluded hashes to check", exclusions.size()); + + var keysToDelete = + exclusions.stream() + .map(exclusion -> processSingleExclusion(exclusion.getFileHash())) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + + if (!keysToDelete.isEmpty()) { + log.info("Deleting {} objects from S3", keysToDelete.size()); + s3Service.deleteObjects(keysToDelete); + } + + log.info("Finished cleanup of excluded images."); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional processSingleExclusion(String fileHash) { + var imageOpt = imageRepository.findByFileHash(fileHash); + if (imageOpt.isEmpty()) { + return Optional.empty(); + } + + var image = imageOpt.get(); + var imageId = image.getId(); + var objectKey = image.getObjectKey(); + + log.info("Processing exclusion for image {} (hash: {})", imageId, fileHash); + + // 1. Remove from Manga covers + var mangasWithCover = mangaRepository.findAllByCoverImage_Id(imageId); + if (!mangasWithCover.isEmpty()) { + log.info("Removing image from {} manga covers", mangasWithCover.size()); + mangasWithCover.forEach( + manga -> { + manga.setCoverImage(null); + mangaRepository.save(manga); + }); + } + + // 2. Remove from Manga content and reindex + var contentImages = mangaContentImageRepository.findAllByImage_Id(imageId); + if (!contentImages.isEmpty()) { + log.info("Removing image from {} manga content entries", contentImages.size()); + contentImages.forEach( + mci -> { + var mangaContent = mci.getMangaContent(); + mangaContentImageRepository.delete(mci); + reindexMangaContent(mangaContent.getId()); + }); + } + + imageRepository.delete(image); + + return Optional.of(objectKey); + } + + private void reindexMangaContent(Long mangaContentId) { + var remainingImages = + mangaContentImageRepository.findAllByMangaContent_IdOrderByPositionAsc(mangaContentId); + + for (var i = 0; i < remainingImages.size(); i++) { + var img = remainingImages.get(i); + if (img.getPosition() != i) { + img.setPosition(i); + mangaContentImageRepository.save(img); + } + } + } } diff --git a/src/main/java/com/magamochi/image/task/ExclusionCleanupTask.java b/src/main/java/com/magamochi/image/task/ExclusionCleanupTask.java new file mode 100644 index 0000000..06b3761 --- /dev/null +++ b/src/main/java/com/magamochi/image/task/ExclusionCleanupTask.java @@ -0,0 +1,28 @@ +package com.magamochi.image.task; + +import com.magamochi.image.service.ImageExclusionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class ExclusionCleanupTask { + @Value("${image-service.exclusion-cleanup-enabled:true}") + private Boolean cleanupEnabled; + + private final ImageExclusionService exclusionService; + + @Scheduled(cron = "${image-service.exclusion-cleanup-cron:@weekly}") + public void cleanupExcludedImagesScheduled() { + if (!cleanupEnabled) { + log.info("Excluded image cleanup is disabled."); + return; + } + + exclusionService.cleanupExcludedImages(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bc40419..797fcb9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,8 @@ routing-key: image-service: clean-up-enabled: ${IMAGE_SERVICE_CLEAN_UP_ENABLED:false} cron-expression: "@weekly" + exclusion-cleanup-enabled: ${IMAGE_SERVICE_EXCLUSION_CLEANUP_ENABLED:true} + exclusion-cleanup-cron: "@weekly" content-providers: update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false}