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
6 changed files with 137 additions and 3 deletions
Showing only changes of commit 189769b848 - Show all commits

View File

@ -3,6 +3,7 @@ package com.magamochi.catalog.model.repository;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@ -15,4 +16,6 @@ public interface MangaRepository
Optional<Manga> findByMalId(Long malId); Optional<Manga> findByMalId(Long malId);
Optional<Manga> findByAniListId(Long aniListId); Optional<Manga> findByAniListId(Long aniListId);
List<Manga> findAllByCoverImage_Id(UUID imageId);
} }

View File

@ -1,12 +1,14 @@
package com.magamochi.content.model.repository; package com.magamochi.content.model.repository;
import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.content.model.entity.MangaContentImage; import com.magamochi.content.model.entity.MangaContentImage;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaContentImageRepository extends JpaRepository<MangaContentImage, Long> { public interface MangaContentImageRepository extends JpaRepository<MangaContentImage, Long> {
List<MangaContentImage> findAllByMangaContent(MangaContent mangaContent);
boolean existsByMangaContent_IdAndPosition(Long mangaContentId, int position); boolean existsByMangaContent_IdAndPosition(Long mangaContentId, int position);
List<MangaContentImage> findAllByImage_Id(UUID imageId);
List<MangaContentImage> findAllByMangaContent_IdOrderByPositionAsc(Long mangaContentId);
} }

View File

@ -2,6 +2,7 @@ package com.magamochi.controller;
import com.magamochi.client.NtfyClient; import com.magamochi.client.NtfyClient;
import com.magamochi.common.model.dto.DefaultResponseDTO; import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.image.service.ImageExclusionService;
import com.magamochi.image.task.ImageCleanupTask; import com.magamochi.image.task.ImageCleanupTask;
import com.magamochi.user.repository.UserRepository; import com.magamochi.user.repository.UserRepository;
import com.magamochi.userinteraction.task.MangaFollowUpdateTask; import com.magamochi.userinteraction.task.MangaFollowUpdateTask;
@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ManagementController { public class ManagementController {
private final ImageCleanupTask imageCleanupTask; private final ImageCleanupTask imageCleanupTask;
private final ImageExclusionService imageExclusionService;
private final MangaFollowUpdateTask mangaFollowUpdateTask; private final MangaFollowUpdateTask mangaFollowUpdateTask;
private final UserRepository userRepository; private final UserRepository userRepository;
private final NtfyClient ntfyClient; private final NtfyClient ntfyClient;
@ -30,6 +32,18 @@ public class ManagementController {
return DefaultResponseDTO.ok().build(); 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<Void> imageExclusionCleanup() {
imageExclusionService.cleanupExcludedImages();
return DefaultResponseDTO.ok().build();
}
@Operation( @Operation(
summary = "Trigger user follow update", summary = "Trigger user follow update",
description = "Trigger user follow update", description = "Trigger user follow update",

View File

@ -2,17 +2,22 @@ package com.magamochi.image.service;
import static java.util.Objects.isNull; 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.entity.MangaContentImage;
import com.magamochi.content.model.repository.MangaContentImageRepository;
import com.magamochi.image.model.entity.ImageExclusion; import com.magamochi.image.model.entity.ImageExclusion;
import com.magamochi.image.model.repository.ImageExclusionRepository; import com.magamochi.image.model.repository.ImageExclusionRepository;
import com.magamochi.image.model.repository.ImageRepository;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Log4j2 @Log4j2
@ -20,8 +25,12 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ImageExclusionService { public class ImageExclusionService {
private final ImageService imageService; private final ImageService imageService;
private final S3Service s3Service;
private final ImageExclusionRepository exclusionRepository; private final ImageExclusionRepository exclusionRepository;
private final ImageRepository imageRepository;
private final MangaRepository mangaRepository;
private final MangaContentImageRepository mangaContentImageRepository;
@Transactional @Transactional
public void excludeImageByUuid(UUID imageId) { public void excludeImageByUuid(UUID imageId) {
@ -55,4 +64,80 @@ public class ImageExclusionService {
.filter(img -> !excludedHashes.contains(img.getImage().getFileHash())) .filter(img -> !excludedHashes.contains(img.getImage().getFileHash()))
.toList(); .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<String> 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);
}
}
}
} }

View File

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

View File

@ -90,6 +90,8 @@ routing-key:
image-service: image-service:
clean-up-enabled: ${IMAGE_SERVICE_CLEAN_UP_ENABLED:false} clean-up-enabled: ${IMAGE_SERVICE_CLEAN_UP_ENABLED:false}
cron-expression: "@weekly" cron-expression: "@weekly"
exclusion-cleanup-enabled: ${IMAGE_SERVICE_EXCLUSION_CLEANUP_ENABLED:true}
exclusion-cleanup-cron: "@weekly"
content-providers: content-providers:
update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false} update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false}