Compare commits
1 Commits
main
...
feat/cover
| Author | SHA1 | Date | |
|---|---|---|---|
| aa957bf707 |
10
pom.xml
10
pom.xml
@ -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>
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package com.magamochi.catalog.queue.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record MangaCoverThumbnailUpdateCommand(Long entityId, UUID thumbnailImageId) {}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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) {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/magamochi/image/util/HashUtils.java
Normal file
27
src/main/java/com/magamochi/image/util/HashUtils.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
Loading…
x
Reference in New Issue
Block a user