Compare commits

...

1 Commits

17 changed files with 298 additions and 22 deletions

10
pom.xml
View File

@ -140,6 +140,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.sksamuel.scrimage</groupId>
<artifactId>scrimage-core</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>com.sksamuel.scrimage</groupId>
<artifactId>scrimage-webp</artifactId>
<version>4.3.0</version>
</dependency>
</dependencies>
<build>

View File

@ -26,7 +26,9 @@ public record MangaListDTO(
return new MangaListDTO(
manga.getId(),
manga.getTitle(),
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getObjectKey() : null,
nonNull(manga.getCoverThumbnail())
? manga.getCoverThumbnail().getObjectKey()
: (nonNull(manga.getCoverImage()) ? manga.getCoverImage().getObjectKey() : null),
manga.getStatus(),
manga.getPublishedFrom(),
manga.getPublishedTo(),

View File

@ -45,6 +45,10 @@ public class Manga {
@JoinColumn(name = "cover_image_id")
private Image coverImage;
@ManyToOne
@JoinColumn(name = "cover_thumbnail_id")
private Image coverThumbnail;
private Double score;
private OffsetDateTime publishedFrom;

View File

@ -0,0 +1,5 @@
package com.magamochi.catalog.queue.command;
import java.util.UUID;
public record MangaCoverThumbnailUpdateCommand(Long entityId, UUID thumbnailImageId) {}

View File

@ -0,0 +1,22 @@
package com.magamochi.catalog.queue.consumer;
import com.magamochi.catalog.queue.command.MangaCoverThumbnailUpdateCommand;
import com.magamochi.catalog.service.MangaUpdateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaCoverThumbnailUpdateConsumer {
private final MangaUpdateService mangaUpdateService;
@RabbitListener(
queues = "${queues.manga-cover-thumbnail-update:mangamochi.manga.cover.thumbnail.update}")
public void receiveMangaCoverThumbnailUpdateCommand(MangaCoverThumbnailUpdateCommand command) {
log.info("Received manga cover thumbnail update command: {}", command);
mangaUpdateService.updateMangaCoverThumbnail(command.entityId(), command.thumbnailImageId());
}
}

View File

@ -13,6 +13,8 @@ import com.magamochi.catalog.queue.producer.MangaUpdateProducer;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.common.queue.command.ImageFetchCommand;
import com.magamochi.common.queue.producer.ImageFetchProducer;
import com.magamochi.image.queue.command.ThumbnailGenerateCommand;
import com.magamochi.image.queue.producer.ThumbnailGenerateProducer;
import com.magamochi.image.service.ImageService;
import jakarta.transaction.Transactional;
import java.util.UUID;
@ -33,6 +35,7 @@ public class MangaUpdateService {
private final MangaUpdateProducer mangaUpdateProducer;
private final ImageFetchProducer imageFetchProducer;
private final ThumbnailGenerateProducer thumbnailGenerateProducer;
public void updateMangas() {
var mangas = mangaService.findAll();
@ -67,9 +70,27 @@ public class MangaUpdateService {
manga.setCoverImage(image);
thumbnailGenerateProducer.publishThumbnailGenerateCommand(
new ThumbnailGenerateCommand(mangaId, imageId, ContentType.MANGA_COVER));
log.info("Manga with ID {} cover image updated successfully (Image ID {})", mangaId, imageId);
}
@Transactional
public void updateMangaCoverThumbnail(long mangaId, UUID thumbnailId) {
log.info("Updating cover thumbnail for manga with ID {}", mangaId);
var manga = mangaService.find(mangaId);
var thumbnailImage = imageService.find(thumbnailId);
manga.setCoverThumbnail(thumbnailImage);
log.info(
"Manga with ID {} cover thumbnail updated successfully (Image ID {})",
mangaId,
thumbnailId);
}
private MangaDataDTO fetchExternalMangaData(Manga manga) {
Exception fetchException = null;

View File

@ -50,6 +50,18 @@ public class RabbitConfig {
@Value("${queues.manga-content-download}")
private String mangaContentDownloadQueue;
@Value("${queues.thumbnail-generate}")
private String thumbnailGenerateQueue;
@Value("${queues.manga-cover-thumbnail-update}")
private String mangaCoverThumbnailUpdateQueue;
@Value("${routing-key.thumbnail-generate:mangamochi.image.thumbnail.generate}")
private String thumbnailGenerateRoutingKey;
@Value("${routing-key.manga-cover-thumbnail-update:mangamochi.manga.cover.thumbnail.update}")
private String mangaCoverThumbnailUpdateRoutingKey;
@Bean
public TopicExchange imageUpdatesExchange() {
return new TopicExchange(imageUpdatesTopic);
@ -208,6 +220,54 @@ public class RabbitConfig {
return QueueBuilder.nonDurable(mangaContentDownloadQueue + ".dlq").build();
}
@Bean
public Queue thumbnailGenerateQueue() {
return QueueBuilder.nonDurable(thumbnailGenerateQueue)
.deadLetterExchange("")
.deadLetterRoutingKey(thumbnailGenerateQueue + ".dlq")
.build();
}
@Bean
public Queue thumbnailGenerateDlq() {
return QueueBuilder.nonDurable(thumbnailGenerateQueue + ".dlq").build();
}
@Bean
public Queue mangaCoverThumbnailUpdateQueue() {
return QueueBuilder.nonDurable(mangaCoverThumbnailUpdateQueue)
.deadLetterExchange("")
.deadLetterRoutingKey(mangaCoverThumbnailUpdateQueue + ".dlq")
.build();
}
@Bean
public Queue mangaCoverThumbnailUpdateDlq() {
return QueueBuilder.nonDurable(mangaCoverThumbnailUpdateQueue + ".dlq").build();
}
@Bean
public Binding bindingThumbnailGenerateQueue(
Queue thumbnailGenerateQueue, TopicExchange imageUpdatesExchange) {
return new Binding(
thumbnailGenerateQueue.getName(),
Binding.DestinationType.QUEUE,
imageUpdatesExchange.getName(),
String.format(thumbnailGenerateRoutingKey + ".%s", "#"),
null);
}
@Bean
public Binding bindingMangaCoverThumbnailUpdateQueue(
Queue mangaCoverThumbnailUpdateQueue, TopicExchange imageUpdatesExchange) {
return new Binding(
mangaCoverThumbnailUpdateQueue.getName(),
Binding.DestinationType.QUEUE,
imageUpdatesExchange.getName(),
mangaCoverThumbnailUpdateRoutingKey,
null);
}
// TODO: remove unused queues
@Value("${rabbit-mq.queues.manga-follow-update-chapter}")

View File

@ -2,4 +2,4 @@ package com.magamochi.common.queue.command;
import java.util.UUID;
public record ImageUpdateCommand(long entityId, UUID imageId) {}
public record ImageUpdateCommand(Long entityId, UUID imageId) {}

View File

@ -0,0 +1,6 @@
package com.magamochi.image.queue.command;
import com.magamochi.common.model.enumeration.ContentType;
import java.util.UUID;
public record ThumbnailGenerateCommand(Long entityId, UUID imageId, ContentType contentType) {}

View File

@ -0,0 +1,27 @@
package com.magamochi.image.queue.consumer;
import com.magamochi.image.queue.command.ThumbnailGenerateCommand;
import com.magamochi.image.service.ThumbnailGenerateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class ThumbnailGenerateConsumer {
private final ThumbnailGenerateService thumbnailGenerateService;
@RabbitListener(queues = "${queues.thumbnail-generate:mangamochi.image.thumbnail.generate}")
public void receiveThumbnailGenerateCommand(ThumbnailGenerateCommand command) {
log.info("Received thumbnail generate command: {}", command);
try {
thumbnailGenerateService.generateThumbnail(
command.entityId(), command.imageId(), command.contentType());
} catch (Exception e) {
log.error("Failed to generate thumbnail for image {}.", command.imageId(), e);
}
}
}

View File

@ -0,0 +1,25 @@
package com.magamochi.image.queue.producer;
import com.magamochi.catalog.queue.command.MangaCoverThumbnailUpdateCommand;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaCoverThumbnailUpdateProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${topics.image-updates}")
private String imageUpdatesTopic;
@Value("${routing-key.manga-cover-thumbnail-update:mangamochi.manga.cover.thumbnail.update}")
private String mangaCoverThumbnailUpdateRoutingKey;
public void publishMangaCoverThumbnailUpdateCommand(MangaCoverThumbnailUpdateCommand command) {
rabbitTemplate.convertAndSend(imageUpdatesTopic, mangaCoverThumbnailUpdateRoutingKey, command);
}
}

View File

@ -0,0 +1,28 @@
package com.magamochi.image.queue.producer;
import com.magamochi.image.queue.command.ThumbnailGenerateCommand;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class ThumbnailGenerateProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${topics.image-updates}")
private String imageUpdatesTopic;
@Value("${routing-key.thumbnail-generate:mangamochi.image.thumbnail.generate}")
private String thumbnailGenerateRoutingKey;
public void publishThumbnailGenerateCommand(ThumbnailGenerateCommand command) {
var routingKey =
String.format(
thumbnailGenerateRoutingKey + ".%s", command.contentType().name().toLowerCase());
rabbitTemplate.convertAndSend(imageUpdatesTopic, routingKey, command);
}
}

View File

@ -4,12 +4,12 @@ import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.image.util.HashUtils;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@ -54,7 +54,7 @@ public class ImageFetchService {
throws NoSuchAlgorithmException {
var fileContentType = resolveContentType(httpResponse, imageBytes);
var fileHash = computeHash(imageBytes);
var fileHash = HashUtils.computeHash(imageBytes);
return imageManagerService.upload(
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
@ -76,22 +76,4 @@ public class ImageFetchService {
return tika.detect(fileBytes);
}
private String computeHash(byte[] content) throws NoSuchAlgorithmException {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(content);
var hexString = new StringBuilder(2 * hashBytes.length);
for (byte b : hashBytes) {
var hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@ -0,0 +1,50 @@
package com.magamochi.image.service;
import com.magamochi.catalog.queue.command.MangaCoverThumbnailUpdateCommand;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.image.queue.producer.MangaCoverThumbnailUpdateProducer;
import com.magamochi.image.util.HashUtils;
import com.sksamuel.scrimage.ImmutableImage;
import com.sksamuel.scrimage.webp.WebpWriter;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class ThumbnailGenerateService {
private final ImageService imageService;
private final MangaCoverThumbnailUpdateProducer mangaCoverThumbnailUpdateProducer;
public void generateThumbnail(Long entityId, UUID imageId, ContentType contentType)
throws IOException, NoSuchAlgorithmException {
var imageEntity = imageService.find(imageId);
try (var stream = imageService.getStream(imageEntity)) {
var imageBytes = stream.readAllBytes();
var thumbnailBytes =
ImmutableImage.loader()
.fromBytes(imageBytes)
.scaleToWidth(300)
.forWriter(WebpWriter.DEFAULT)
.bytes();
var thumbnailId =
imageService.upload(
thumbnailBytes,
"image/webp",
contentType.name().toLowerCase(),
HashUtils.computeHash(thumbnailBytes));
if (contentType.equals(ContentType.MANGA_COVER)) {
mangaCoverThumbnailUpdateProducer.publishMangaCoverThumbnailUpdateCommand(
new MangaCoverThumbnailUpdateCommand(entityId, thumbnailId));
}
}
}
}

View File

@ -0,0 +1,27 @@
package com.magamochi.image.util;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class HashUtils {
public static String computeHash(byte[] content) throws NoSuchAlgorithmException {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(content);
var hexString = new StringBuilder(2 * hashBytes.length);
for (byte b : hashBytes) {
var hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@ -82,6 +82,8 @@ queues:
manga-cover-update: ${MANGA_COVER_UDPATE_QUEUE:mangamochi.manga.cover.update}
file-import: ${FILE_IMPORT_QUEUE:mangamochi.file.import}
manga-content-download: ${MANGA_CONTENT_DOWNLOAD_QUEUE:mangamochi.manga.content.download}
thumbnail-generate: ${THUMBNAIL_GENERATE_QUEUE:mangamochi.image.thumbnail.generate}
manga-cover-thumbnail-update: ${MANGA_COVER_THUMBNAIL_UPDATE_QUEUE:mangamochi.manga.cover.thumbnail.update}
routing-key:
image-update: ${IMAGE_UPDATE_ROUTING_KEY:mangamochi.image.update}

View File

@ -0,0 +1,5 @@
ALTER TABLE mangas
ADD COLUMN cover_thumbnail_id UUID;
ALTER TABLE mangas
ADD CONSTRAINT fk_mangas_cover_thumbnail FOREIGN KEY (cover_thumbnail_id) REFERENCES images (id);