feat: add additional cover art actions and settings (#1938)

* feat: add cover image auto cropping when oversized

* feat: add smart cropping to ignore sections of constant colours in cover images

* fix: description implied cbx files would be excluded from cover regeneration when they are not

* feat: add options to bulk edit cover images in the library view

* fix: resolve issues with batching requests and data validation for cover images
This commit is contained in:
CounterClops
2025-12-20 02:20:50 +08:00
committed by GitHub
parent 4e6842c189
commit 54108754f9
26 changed files with 745 additions and 37 deletions

View File

@@ -156,6 +156,27 @@ public class MetadataController {
bookMetadataService.regenerateCover(bookId);
}
@Operation(summary = "Regenerate covers for selected books", description = "Regenerate covers for a list of books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Cover regeneration started successfully")
@PostMapping("/bulk-regenerate-covers")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> regenerateCoversForBooks(
@Parameter(description = "List of book IDs") @Validated @RequestBody BulkBookIdsRequest request) {
bookMetadataService.regenerateCoversForBooks(request.getBookIds());
return ResponseEntity.noContent().build();
}
@Operation(summary = "Upload cover image for multiple books", description = "Upload a cover image to apply to multiple books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Cover upload started successfully")
@PostMapping("/bulk-upload-cover")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> bulkUploadCover(
@Parameter(description = "Cover image file") @RequestParam("file") MultipartFile file,
@Parameter(description = "Comma-separated book IDs") @RequestParam("bookIds") @jakarta.validation.constraints.NotEmpty java.util.Set<Long> bookIds) {
bookMetadataService.updateCoverImageFromFileForBooks(bookIds, file);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Recalculate metadata match scores", description = "Recalculate match scores for all metadata. Requires admin.")
@ApiResponse(responseCode = "204", description = "Match scores recalculated successfully")
@PostMapping("/metadata/recalculate-match-scores")

View File

@@ -55,7 +55,7 @@ public enum ApiError {
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists"),;
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists");
private final HttpStatus status;
private final String message;

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.Set;
@Data
public class BulkBookIdsRequest {
@NotEmpty(message = "At least one book ID is required")
private Set<Long> bookIds;
}

View File

@@ -16,6 +16,7 @@ public enum AppSettingKey {
METADATA_PERSISTENCE_SETTINGS("metadata_persistence_settings", true, false),
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
KOBO_SETTINGS("kobo_settings", true, false),
COVER_CROPPING_SETTINGS("cover_cropping_settings", true, false),
AUTO_BOOK_SEARCH("auto_book_search", false, false),
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),

View File

@@ -33,4 +33,5 @@ public class AppSettings {
private MetadataPersistenceSettings metadataPersistenceSettings;
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
private KoboSettings koboSettings;
private CoverCroppingSettings coverCroppingSettings;
}

View File

@@ -0,0 +1,13 @@
package com.adityachandel.booklore.model.dto.settings;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class CoverCroppingSettings {
private boolean verticalCroppingEnabled;
private boolean horizontalCroppingEnabled;
private double aspectRatioThreshold;
private boolean smartCroppingEnabled;
}

View File

@@ -2,9 +2,12 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
public interface BookMetadataRepository extends JpaRepository<BookMetadataEntity, Long> {
@@ -12,6 +15,11 @@ public interface BookMetadataRepository extends JpaRepository<BookMetadataEntity
@Query("SELECT m FROM BookMetadataEntity m WHERE m.bookId IN :bookIds")
List<BookMetadataEntity> getMetadataForBookIds(@Param("bookIds") List<Long> bookIds);
@Modifying
@Transactional
@Query("UPDATE BookMetadataEntity m SET m.coverUpdatedOn = :timestamp WHERE m.bookId = :bookId")
void updateCoverTimestamp(@Param("bookId") Long bookId, @Param("timestamp") Instant timestamp);
List<BookMetadataEntity> findAllByAuthorsContaining(AuthorEntity author);
List<BookMetadataEntity> findAllByCategoriesContaining(CategoryEntity category);

View File

@@ -95,6 +95,7 @@ public class AppSettingService {
builder.metadataPersistenceSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PERSISTENCE_SETTINGS, MetadataPersistenceSettings.class, settingPersistenceHelper.getDefaultMetadataPersistenceSettings(), true));
builder.metadataPublicReviewsSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PUBLIC_REVIEWS_SETTINGS, MetadataPublicReviewsSettings.class, settingPersistenceHelper.getDefaultMetadataPublicReviewsSettings(), true));
builder.koboSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.KOBO_SETTINGS, KoboSettings.class, settingPersistenceHelper.getDefaultKoboSettings(), true));
builder.coverCroppingSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.COVER_CROPPING_SETTINGS, CoverCroppingSettings.class, settingPersistenceHelper.getDefaultCoverCroppingSettings(), true));
builder.autoBookSearch(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.AUTO_BOOK_SEARCH, "true")));
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));

View File

@@ -259,4 +259,13 @@ public class SettingPersistenceHelper {
.forceEnableHyphenation(false)
.build();
}
public CoverCroppingSettings getDefaultCoverCroppingSettings() {
return CoverCroppingSettings.builder()
.verticalCroppingEnabled(false)
.horizontalCroppingEnabled(false)
.aspectRatioThreshold(2.5)
.smartCroppingEnabled(false)
.build();
}
}

View File

@@ -78,8 +78,6 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
try {
boolean saved = fileService.saveCoverImages(image, bookEntity.getId());
if (saved) {
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return true;
}
} finally {

View File

@@ -80,10 +80,6 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
originalImage.flush();
}
if (saved) {
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
}
return saved;
} catch (Exception e) {

View File

@@ -70,8 +70,6 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce
}
boolean saved = saveCoverImage(coverData, bookEntity.getId());
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return saved;
} catch (Exception e) {

View File

@@ -62,10 +62,7 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
@Override
public boolean generateCover(BookEntity bookEntity) {
try (PDDocument pdf = Loader.loadPDF(new File(FileUtils.getBookFullPath(bookEntity)))) {
boolean saved = generateCoverImageAndSave(bookEntity.getId(), pdf);
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return saved;
return generateCoverImageAndSave(bookEntity.getId(), pdf);
} catch (OutOfMemoryError e) {
// Note: Catching OOM is generally discouraged, but for batch processing
// of potentially large/corrupted PDFs, we prefer graceful degradation

View File

@@ -48,6 +48,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
@@ -57,6 +58,8 @@ import java.util.stream.Collectors;
@AllArgsConstructor
public class BookMetadataService {
private static final int BATCH_SIZE = 100;
private final BookRepository bookRepository;
private final BookMapper bookMapper;
private final BookMetadataMapper bookMetadataMapper;
@@ -157,6 +160,76 @@ public class BookMetadataService {
return updateCover(bookId, (writer, book) -> writer.replaceCoverImageFromUpload(book, file));
}
public void updateCoverImageFromFileForBooks(Set<Long> bookIds, MultipartFile file) {
validateCoverFile(file);
byte[] coverImageBytes = extractBytesFromMultipartFile(file);
List<BookCoverInfo> unlockedBooks = getUnlockedBookCoverInfos(bookIds);
SecurityContextVirtualThread.runWithSecurityContext(() ->
processBulkCoverUpdate(unlockedBooks, coverImageBytes));
}
private void validateCoverFile(MultipartFile file) {
if (file.isEmpty()) {
throw ApiError.INVALID_INPUT.createException("Uploaded file is empty");
}
String contentType = file.getContentType();
if (contentType == null || (!contentType.toLowerCase().startsWith("image/jpeg") && !contentType.toLowerCase().startsWith("image/png"))) {
throw ApiError.INVALID_INPUT.createException("Only JPEG and PNG files are allowed");
}
long maxFileSize = 5L * 1024 * 1024;
if (file.getSize() > maxFileSize) {
throw ApiError.FILE_TOO_LARGE.createException(5);
}
}
private byte[] extractBytesFromMultipartFile(MultipartFile file) {
try {
return file.getBytes();
} catch (Exception e) {
log.error("Failed to read cover file: {}", e.getMessage());
throw new RuntimeException("Failed to read cover file", e);
}
}
private record BookCoverInfo(Long id, String title) {}
private List<BookCoverInfo> getUnlockedBookCoverInfos(Set<Long> bookIds) {
return bookQueryService.findAllWithMetadataByIds(bookIds).stream()
.filter(book -> !isCoverLocked(book))
.map(book -> new BookCoverInfo(book.getId(), book.getMetadata().getTitle()))
.toList();
}
private boolean isCoverLocked(BookEntity book) {
return book.getMetadata().getCoverLocked() != null && book.getMetadata().getCoverLocked();
}
private void processBulkCoverUpdate(List<BookCoverInfo> books, byte[] coverImageBytes) {
try {
int total = books.size();
notificationService.sendMessage(Topic.LOG, LogNotification.info("Started updating covers for " + total + " selected book(s)"));
int current = 1;
for (BookCoverInfo bookInfo : books) {
try {
String progress = "(" + current + "/" + total + ") ";
notificationService.sendMessage(Topic.LOG, LogNotification.info(progress + "Updating cover for: " + bookInfo.title()));
fileService.createThumbnailFromBytes(bookInfo.id(), coverImageBytes);
log.info("{}Successfully updated cover for book ID {} ({})", progress, bookInfo.id(), bookInfo.title());
} catch (Exception e) {
log.error("Failed to update cover for book ID {}: {}", bookInfo.id(), e.getMessage(), e);
}
pauseAfterBatchIfNeeded(current, total);
current++;
}
notificationService.sendMessage(Topic.LOG, LogNotification.info("Finished updating covers for selected books"));
} catch (Exception e) {
log.error("Error during cover update: {}", e.getMessage(), e);
notificationService.sendMessage(Topic.LOG, LogNotification.error("Error occurred during cover update"));
}
}
@Transactional
public BookMetadata updateCoverImageFromUrl(Long bookId, String url) {
fileService.createThumbnailFromUrl(bookId, url);
@@ -190,24 +263,83 @@ public class BookMetadataService {
}
}
private record BookRegenerationInfo(Long id, String title, BookFileType bookType) {}
public void regenerateCoversForBooks(Set<Long> bookIds) {
List<BookRegenerationInfo> unlockedBooks = getUnlockedBookRegenerationInfos(bookIds);
SecurityContextVirtualThread.runWithSecurityContext(() ->
processBulkCoverRegeneration(unlockedBooks));
}
private List<BookRegenerationInfo> getUnlockedBookRegenerationInfos(Set<Long> bookIds) {
return bookQueryService.findAllWithMetadataByIds(bookIds).stream()
.filter(book -> !isCoverLocked(book))
.map(book -> new BookRegenerationInfo(book.getId(), book.getMetadata().getTitle(), book.getBookType()))
.toList();
}
private void processBulkCoverRegeneration(List<BookRegenerationInfo> books) {
try {
int total = books.size();
notificationService.sendMessage(Topic.LOG, LogNotification.info("Started regenerating covers for " + total + " selected book(s)"));
int current = 1;
for (BookRegenerationInfo bookInfo : books) {
try {
String progress = "(" + current + "/" + total + ") ";
notificationService.sendMessage(Topic.LOG, LogNotification.info(progress + "Regenerating cover for: " + bookInfo.title()));
regenerateCoverForBookId(bookInfo);
log.info("{}Successfully regenerated cover for book ID {} ({})", progress, bookInfo.id(), bookInfo.title());
} catch (Exception e) {
log.error("Failed to regenerate cover for book ID {}: {}", bookInfo.id(), e.getMessage(), e);
}
pauseAfterBatchIfNeeded(current, total);
current++;
}
notificationService.sendMessage(Topic.LOG, LogNotification.info("Finished regenerating covers for selected books"));
} catch (Exception e) {
log.error("Error during cover regeneration: {}", e.getMessage(), e);
notificationService.sendMessage(Topic.LOG, LogNotification.error("Error occurred during cover regeneration"));
}
}
private void pauseAfterBatchIfNeeded(int current, int total) {
if (current % BATCH_SIZE == 0 && current < total) {
try {
log.info("Processed {} items, pausing briefly before next batch...", current);
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Batch pause interrupted");
}
}
}
private void regenerateCoverForBookId(BookRegenerationInfo bookInfo) {
bookRepository.findById(bookInfo.id()).ifPresent(book -> {
BookFileProcessor processor = processorRegistry.getProcessorOrThrow(bookInfo.bookType());
processor.generateCover(book);
});
}
public void regenerateCovers() {
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
List<BookEntity> books = bookQueryService.getAllFullBookEntities().stream()
.filter(book -> book.getMetadata().getCoverLocked() == null || !book.getMetadata().getCoverLocked())
.filter(book -> !isCoverLocked(book))
.toList();
int total = books.size();
notificationService.sendMessage(Topic.LOG, LogNotification.info("Started regenerating covers for " + total + " books"));
int[] current = {1};
int current = 1;
for (BookEntity book : books) {
try {
String progress = "(" + current[0] + "/" + total + ") ";
String progress = "(" + current + "/" + total + ") ";
regenerateCoverForBook(book, progress);
} catch (Exception e) {
log.error("Failed to regenerate cover for book ID {}: {}", book.getId(), e.getMessage());
log.error("Failed to regenerate cover for book ID {}: {}", book.getId(), e.getMessage(), e);
}
current[0]++;
current++;
}
notificationService.sendMessage(Topic.LOG, LogNotification.info("Finished regenerating covers"));
} catch (Exception e) {
@@ -219,8 +351,7 @@ public class BookMetadataService {
private void regenerateCoverForBook(BookEntity book, String progress) {
String title = book.getMetadata().getTitle();
String message = progress + "Regenerating cover for: " + title;
notificationService.sendMessage(Topic.LOG, LogNotification.info(message));
notificationService.sendMessage(Topic.LOG, LogNotification.info(progress + "Regenerating cover for: " + title));
BookFileProcessor processor = processorRegistry.getProcessorOrThrow(book.getBookType());
processor.generateCover(book);

View File

@@ -357,7 +357,6 @@ public class BookMetadataUpdater {
if (!set) return;
if (!StringUtils.hasText(m.getThumbnailUrl()) || isLocalOrPrivateUrl(m.getThumbnailUrl())) return;
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
e.setCoverUpdatedOn(Instant.now());
}
private void updateLocks(BookMetadata m, BookMetadataEntity e) {

View File

@@ -2,7 +2,10 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.settings.CoverCroppingSettings;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.repository.BookMetadataRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
@@ -38,6 +41,12 @@ public class FileService {
private final AppProperties appProperties;
private final RestTemplate restTemplate;
private final AppSettingService appSettingService;
private final BookMetadataRepository bookMetadataRepository;
private static final double TARGET_COVER_ASPECT_RATIO = 1.5;
private static final int SMART_CROP_COLOR_TOLERANCE = 30;
private static final double SMART_CROP_MARGIN_PERCENT = 0.02;
// @formatter:off
private static final String IMAGES_DIR = "images";
@@ -224,6 +233,27 @@ public class FileService {
}
}
public void createThumbnailFromBytes(long bookId, byte[] imageBytes) {
try {
BufferedImage originalImage;
try (InputStream inputStream = new java.io.ByteArrayInputStream(imageBytes)) {
originalImage = ImageIO.read(inputStream);
}
if (originalImage == null) {
throw ApiError.IMAGE_NOT_FOUND.createException();
}
boolean success = saveCoverImages(originalImage, bookId);
if (!success) {
throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images");
}
originalImage.flush();
log.info("Cover images created and saved from bytes for book ID: {}", bookId);
} catch (Exception e) {
log.error("An error occurred while creating thumbnail from bytes: {}", e.getMessage(), e);
throw ApiError.FILE_READ_ERROR.createException(e.getMessage());
}
}
public void createThumbnailFromUrl(long bookId, String imageUrl) {
try {
BufferedImage originalImage = downloadImageFromUrl(imageUrl);
@@ -241,6 +271,7 @@ public class FileService {
public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException {
BufferedImage rgbImage = null;
BufferedImage cropped = null;
BufferedImage resized = null;
BufferedImage thumb = null;
try {
@@ -260,6 +291,12 @@ public class FileService {
g.dispose();
// Note: coverImage is not flushed here - caller is responsible for its lifecycle
cropped = applyCoverCropping(rgbImage);
if (cropped != rgbImage) {
rgbImage.flush();
rgbImage = cropped;
}
// Resize original image if too large to prevent OOM
double scale = Math.min(
(double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(),
@@ -278,13 +315,19 @@ public class FileService {
File thumbnailFile = new File(folder, THUMBNAIL_FILENAME);
boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile);
if (originalSaved && thumbnailSaved) {
bookMetadataRepository.updateCoverTimestamp(bookId, Instant.now());
}
return originalSaved && thumbnailSaved;
} finally {
// Cleanup resources created within this method
// Note: resized may equal rgbImage after reassignment, avoid double-flush
// Note: cropped/resized may equal rgbImage after reassignment, avoid double-flush
if (rgbImage != null) {
rgbImage.flush();
}
if (cropped != null && cropped != rgbImage) {
cropped.flush();
}
if (resized != null && resized != rgbImage) {
resized.flush();
}
@@ -294,6 +337,110 @@ public class FileService {
}
}
private BufferedImage applyCoverCropping(BufferedImage image) {
CoverCroppingSettings settings = appSettingService.getAppSettings().getCoverCroppingSettings();
if (settings == null) {
return image;
}
int width = image.getWidth();
int height = image.getHeight();
double heightToWidthRatio = (double) height / width;
double widthToHeightRatio = (double) width / height;
double threshold = settings.getAspectRatioThreshold();
boolean smartCrop = settings.isSmartCroppingEnabled();
boolean isExtremelyTall = settings.isVerticalCroppingEnabled() && heightToWidthRatio > threshold;
if (isExtremelyTall) {
int croppedHeight = (int) (width * TARGET_COVER_ASPECT_RATIO);
log.debug("Cropping tall image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
width, height, String.format("%.2f", heightToWidthRatio), width, croppedHeight, smartCrop);
return cropFromTop(image, width, croppedHeight, smartCrop);
}
boolean isExtremelyWide = settings.isHorizontalCroppingEnabled() && widthToHeightRatio > threshold;
if (isExtremelyWide) {
int croppedWidth = (int) (height / TARGET_COVER_ASPECT_RATIO);
log.debug("Cropping wide image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
width, height, String.format("%.2f", widthToHeightRatio), croppedWidth, height, smartCrop);
return cropFromLeft(image, croppedWidth, height, smartCrop);
}
return image;
}
private BufferedImage cropFromTop(BufferedImage image, int targetWidth, int targetHeight, boolean smartCrop) {
int startY = 0;
if (smartCrop) {
int contentStartY = findContentStartY(image);
int margin = (int) (targetHeight * SMART_CROP_MARGIN_PERCENT);
startY = Math.max(0, contentStartY - margin);
int maxStartY = image.getHeight() - targetHeight;
startY = Math.min(startY, maxStartY);
}
return image.getSubimage(0, startY, targetWidth, targetHeight);
}
private BufferedImage cropFromLeft(BufferedImage image, int targetWidth, int targetHeight, boolean smartCrop) {
int startX = 0;
if (smartCrop) {
int contentStartX = findContentStartX(image);
int margin = (int) (targetWidth * SMART_CROP_MARGIN_PERCENT);
startX = Math.max(0, contentStartX - margin);
int maxStartX = image.getWidth() - targetWidth;
startX = Math.min(startX, maxStartX);
}
return image.getSubimage(startX, 0, targetWidth, targetHeight);
}
private int findContentStartY(BufferedImage image) {
for (int y = 0; y < image.getHeight(); y++) {
if (!isRowUniformColor(image, y)) {
return y;
}
}
return 0;
}
private int findContentStartX(BufferedImage image) {
for (int x = 0; x < image.getWidth(); x++) {
if (!isColumnUniformColor(image, x)) {
return x;
}
}
return 0;
}
private boolean isRowUniformColor(BufferedImage image, int y) {
int firstPixel = image.getRGB(0, y);
for (int x = 1; x < image.getWidth(); x++) {
if (!colorsAreSimilar(firstPixel, image.getRGB(x, y))) {
return false;
}
}
return true;
}
private boolean isColumnUniformColor(BufferedImage image, int x) {
int firstPixel = image.getRGB(x, 0);
for (int y = 1; y < image.getHeight(); y++) {
if (!colorsAreSimilar(firstPixel, image.getRGB(x, y))) {
return false;
}
}
return true;
}
private boolean colorsAreSimilar(int rgb1, int rgb2) {
int r1 = (rgb1 >> 16) & 0xFF, g1 = (rgb1 >> 8) & 0xFF, b1 = rgb1 & 0xFF;
int r2 = (rgb2 >> 16) & 0xFF, g2 = (rgb2 >> 8) & 0xFF, b2 = rgb2 & 0xFF;
return Math.abs(r1 - r2) <= SMART_CROP_COLOR_TOLERANCE
&& Math.abs(g1 - g2) <= SMART_CROP_COLOR_TOLERANCE
&& Math.abs(b1 - b2) <= SMART_CROP_COLOR_TOLERANCE;
}
public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
bookMetadataEntity.setCoverUpdatedOn(Instant.now());
}

View File

@@ -1,7 +1,11 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.dto.settings.CoverCroppingSettings;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.repository.BookMetadataRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
@@ -41,6 +45,9 @@ class FileServiceTest {
@Mock
private AppProperties appProperties;
@Mock
private AppSettingService appSettingService;
private FileService fileService;
@TempDir
@@ -48,7 +55,17 @@ class FileServiceTest {
@BeforeEach
void setup() {
fileService = new FileService(appProperties, mock(RestTemplate.class)); // mock RestTemplate for most tests
CoverCroppingSettings coverCroppingSettings = CoverCroppingSettings.builder()
.verticalCroppingEnabled(true)
.horizontalCroppingEnabled(true)
.aspectRatioThreshold(2.5)
.build();
AppSettings appSettings = AppSettings.builder()
.coverCroppingSettings(coverCroppingSettings)
.build();
lenient().when(appSettingService.getAppSettings()).thenReturn(appSettings);
fileService = new FileService(appProperties, mock(RestTemplate.class), appSettingService, mock(BookMetadataRepository.class));
}
@Nested
@@ -614,6 +631,116 @@ class FileServiceTest {
}
}
@Nested
@DisplayName("Cover Cropping for Extreme Aspect Ratios")
class CoverCroppingTests {
@Test
@DisplayName("extremely tall image is cropped when vertical cropping enabled")
void extremelyTallImage_isCropped() throws IOException {
// Create an extremely tall image like a web comic page (ratio > 2.5)
int width = 940;
int height = 11280; // ratio = 12:1
BufferedImage tallImage = createTestImage(width, height);
boolean result = fileService.saveCoverImages(tallImage, 100L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(100L)));
assertNotNull(savedCover);
// The image should be cropped to approximately 1.5:1 ratio from the top
double savedRatio = (double) savedCover.getHeight() / savedCover.getWidth();
assertTrue(savedRatio < 3.0,
"Cropped image should have reasonable aspect ratio, was: " + savedRatio);
}
@Test
@DisplayName("extremely wide image is cropped when horizontal cropping enabled")
void extremelyWideImage_isCropped() throws IOException {
// Create an extremely wide image (ratio > 2.5)
int width = 3000;
int height = 400; // width/height ratio = 7.5:1
BufferedImage wideImage = createTestImage(width, height);
boolean result = fileService.saveCoverImages(wideImage, 101L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(101L)));
assertNotNull(savedCover);
// The image should be cropped to a more reasonable aspect ratio
double savedRatio = (double) savedCover.getWidth() / savedCover.getHeight();
assertTrue(savedRatio < 3.0,
"Cropped image should have reasonable aspect ratio, was: " + savedRatio);
}
@Test
@DisplayName("normal aspect ratio image is not cropped")
void normalAspectRatioImage_isNotCropped() throws IOException {
// Create a normal book cover sized image (ratio ~1.5:1)
int width = 600;
int height = 900; // ratio = 1.5:1
BufferedImage normalImage = createTestImage(width, height);
boolean result = fileService.saveCoverImages(normalImage, 102L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(102L)));
assertNotNull(savedCover);
// The image should maintain its original aspect ratio
double originalRatio = (double) height / width;
double savedRatio = (double) savedCover.getHeight() / savedCover.getWidth();
assertEquals(originalRatio, savedRatio, 0.01,
"Normal aspect ratio image should not be cropped");
}
@Test
@DisplayName("cropping is disabled when settings are off")
void croppingDisabled_imageNotCropped() throws IOException {
// Reconfigure with cropping disabled
CoverCroppingSettings disabledSettings = CoverCroppingSettings.builder()
.verticalCroppingEnabled(false)
.horizontalCroppingEnabled(false)
.aspectRatioThreshold(2.5)
.build();
AppSettings appSettings = AppSettings.builder()
.coverCroppingSettings(disabledSettings)
.build();
when(appSettingService.getAppSettings()).thenReturn(appSettings);
// Create an extremely tall image
int width = 400;
int height = 4000; // ratio = 10:1
BufferedImage tallImage = createTestImage(width, height);
boolean result = fileService.saveCoverImages(tallImage, 103L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(103L)));
assertNotNull(savedCover);
// Since the image exceeds max dimensions, it will be scaled, but aspect ratio preserved
double originalRatio = (double) height / width;
double savedRatio = (double) savedCover.getHeight() / savedCover.getWidth();
assertEquals(originalRatio, savedRatio, 0.01,
"Image should not be cropped when cropping is disabled");
}
}
@Nested
@DisplayName("createThumbnailFromFile")
class CreateThumbnailFromFileTests {
@@ -823,12 +950,26 @@ class FileServiceTest {
@Mock
private RestTemplate restTemplate;
@Mock
private AppSettingService appSettingServiceForNetwork;
private FileService fileService;
@BeforeEach
void setup() {
lenient().when(appProperties.getPathConfig()).thenReturn(tempDir.toString());
fileService = new FileService(appProperties, restTemplate);
CoverCroppingSettings coverCroppingSettings = CoverCroppingSettings.builder()
.verticalCroppingEnabled(true)
.horizontalCroppingEnabled(true)
.aspectRatioThreshold(2.5)
.build();
AppSettings appSettings = AppSettings.builder()
.coverCroppingSettings(coverCroppingSettings)
.build();
lenient().when(appSettingServiceForNetwork.getAppSettings()).thenReturn(appSettings);
fileService = new FileService(appProperties, restTemplate, appSettingServiceForNetwork, mock(BookMetadataRepository.class));
}
@Nested
@@ -844,7 +985,8 @@ class FileServiceTest {
byte[] imageBytes = imageToBytes(testImage);
RestTemplate mockRestTemplate = mock(RestTemplate.class);
FileService testFileService = new FileService(appProperties, mockRestTemplate);
AppSettingService mockAppSettingService = mock(AppSettingService.class);
FileService testFileService = new FileService(appProperties, mockRestTemplate, mockAppSettingService, mock(BookMetadataRepository.class));
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(imageBytes);
when(mockRestTemplate.exchange(

View File

@@ -257,6 +257,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
() => this.fetchMetadata(),
() => this.bulkEditMetadata(),
() => this.multiBookEditMetadata(),
() => this.regenerateCoversForSelected(),
);
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
@@ -668,6 +669,38 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks);
}
regenerateCoversForSelected(): void {
if (!this.selectedBooks || this.selectedBooks.size === 0) return;
const count = this.selectedBooks.size;
this.confirmationService.confirm({
message: `Are you sure you want to regenerate covers for ${count} book(s)?`,
header: 'Confirm Cover Regeneration',
icon: 'pi pi-image',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.regenerateCoversForBooks(Array.from(this.selectedBooks)).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Cover Regeneration Started',
detail: `Regenerating covers for ${count} book(s). Refresh the page when complete.`,
life: 3000
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not start cover regeneration.',
life: 3000
});
}
});
}
});
}
moveFiles() {
this.dialogHelperService.openFileMoverDialog(this.selectedBooks);
}

View File

@@ -22,7 +22,8 @@ export class BookMenuService {
autoFetchMetadata: () => void,
fetchMetadata: () => void,
bulkEditMetadata: () => void,
multiBookEditMetadata: () => void): MenuItem[] {
multiBookEditMetadata: () => void,
regenerateCovers: () => void): MenuItem[] {
return [
{
label: 'Auto Fetch Metadata',
@@ -43,6 +44,11 @@ export class BookMenuService {
label: 'Multi-Book Metadata Editor',
icon: 'pi pi-clone',
command: multiBookEditMetadata
},
{
label: 'Regenerate Covers',
icon: 'pi pi-image',
command: regenerateCovers
}
];
}

View File

@@ -432,6 +432,17 @@ export class BookService {
return this.http.post<void>(`${this.url}/${bookId}/regenerate-cover`, {});
}
regenerateCoversForBooks(bookIds: number[]): Observable<void> {
return this.http.post<void>(`${this.url}/bulk-regenerate-covers`, { bookIds });
}
bulkUploadCover(bookIds: number[], file: File): Observable<void> {
const formData = new FormData();
formData.append('file', file);
formData.append('bookIds', bookIds.join(','));
return this.http.post<void>(`${this.url}/bulk-upload-cover`, formData);
}
/*------------------ All the metadata related calls go here ------------------*/

View File

@@ -312,6 +312,49 @@
</div>
</div>
<!-- Cover Image Upload Section -->
<div class="flex flex-col gap-2 border border-gray-700 rounded-md p-4 bg-gray-900">
<label class="flex items-center gap-2 text-base font-semibold">
<i class="pi pi-image"></i>
Cover Image
</label>
<p class="text-sm text-gray-400">
Upload an image to set as the cover for all selected books.
</p>
<div class="flex items-center gap-4 mt-2">
@if (selectedCoverFile) {
<div class="flex items-center gap-2 text-sm text-gray-300">
<i class="pi pi-file-image"></i>
<span>{{ selectedCoverFile.name }}</span>
<p-button
icon="pi pi-times"
[text]="true"
size="small"
severity="danger"
(onClick)="clearCoverFile()"
pTooltip="Remove file"
tooltipPosition="top">
</p-button>
</div>
} @else {
<input
type="file"
#coverFileInput
accept="image/jpeg,image/png,image/webp"
(change)="onCoverFileSelect($event)"
class="hidden">
<p-button
icon="pi pi-upload"
label="Select Image"
[outlined]="true"
size="small"
severity="info"
(onClick)="coverFileInput.click()">
</p-button>
}
</div>
</div>
<div class="flex justify-end mt-4">
<p-button type="submit" label="Apply to Selected" [disabled]="loading"/>
</div>

View File

@@ -41,6 +41,7 @@ export class BulkMetadataUpdateComponent implements OnInit {
mergeMoods = true;
mergeTags = true;
loading = false;
selectedCoverFile: File | null = null;
clearFields = {
authors: false,
@@ -257,13 +258,37 @@ export class BulkMetadataUpdateComponent implements OnInit {
this.loading = true;
this.bookService.updateBooksMetadata(payload).subscribe({
next: () => {
this.loading = false;
this.messageService.add({
severity: 'success',
summary: 'Metadata Updated',
detail: 'Books updated successfully'
});
this.ref.close(true);
if (this.selectedCoverFile) {
this.bookService.bulkUploadCover(this.bookIds, this.selectedCoverFile).subscribe({
next: () => {
this.loading = false;
this.messageService.add({
severity: 'success',
summary: 'Metadata & Cover Updated',
detail: 'Books updated and cover upload started. Refresh the page when complete.'
});
this.ref.close(true);
},
error: err => {
console.error('Bulk cover upload failed:', err);
this.loading = false;
this.messageService.add({
severity: 'warn',
summary: 'Partial Success',
detail: 'Metadata updated but cover upload failed'
});
this.ref.close(true);
}
});
} else {
this.loading = false;
this.messageService.add({
severity: 'success',
summary: 'Metadata Updated',
detail: 'Books updated successfully'
});
this.ref.close(true);
}
},
error: err => {
console.error('Bulk metadata update failed:', err);
@@ -276,4 +301,15 @@ export class BulkMetadataUpdateComponent implements OnInit {
}
});
}
onCoverFileSelect(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedCoverFile = input.files[0];
}
}
clearCoverFile(): void {
this.selectedCoverFile = null;
}
}

View File

@@ -34,7 +34,76 @@
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Regenerates cover images for all EPUB and PDF books (excluding locked ones) from the embedded covers in the file.
Regenerates cover images for all books from the embedded covers in the file.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Vertical Cover Cropping</label>
<p-toggleswitch
[(ngModel)]="coverCroppingSettings.verticalCroppingEnabled"
(onChange)="onCoverCroppingChange()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Automatically crop extremely tall images (like web comics) from the top to create usable cover thumbnails.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Horizontal Cover Cropping</label>
<p-toggleswitch
[(ngModel)]="coverCroppingSettings.horizontalCroppingEnabled"
(onChange)="onCoverCroppingChange()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Automatically crop extremely wide images from the left to create usable cover thumbnails.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Aspect Ratio Threshold: {{ coverCroppingSettings.aspectRatioThreshold }}</label>
<div class="slider-container">
<p-slider
[(ngModel)]="coverCroppingSettings.aspectRatioThreshold"
[min]="1.5"
[max]="3"
[step]="0.1"
(onSlideEnd)="onCoverCroppingChange()">
</p-slider>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Images with aspect ratios exceeding this threshold will be cropped. A value of 2.5 means images more than 2.5x taller (or wider) than normal will be cropped.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Smart Cropping</label>
<p-toggleswitch
[(ngModel)]="coverCroppingSettings.smartCroppingEnabled"
(onChange)="onCoverCroppingChange()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Skip uniform colour regions when determining where to crop. Focuses the cover image on the most relevant content.
</p>
</div>
</div>

View File

@@ -215,3 +215,14 @@
margin-top: 0.5rem;
}
}
.slider-container {
flex: 1;
min-width: 200px;
max-width: 300px;
@media (max-width: 768px) {
min-width: 180px;
max-width: 250px;
}
}

View File

@@ -7,9 +7,10 @@ import {MessageService} from 'primeng/api';
import {AppSettingsService} from '../../../shared/service/app-settings.service';
import {BookService} from '../../book/service/book.service';
import {AppSettingKey, AppSettings} from '../../../shared/model/app-settings.model';
import {AppSettingKey, AppSettings, CoverCroppingSettings} from '../../../shared/model/app-settings.model';
import {filter, take} from 'rxjs/operators';
import {InputText} from 'primeng/inputtext';
import {Slider} from 'primeng/slider';
@Component({
selector: 'app-global-preferences',
@@ -18,7 +19,8 @@ import {InputText} from 'primeng/inputtext';
Button,
ToggleSwitch,
FormsModule,
InputText
InputText,
Slider
],
templateUrl: './global-preferences.component.html',
styleUrl: './global-preferences.component.scss'
@@ -30,6 +32,13 @@ export class GlobalPreferencesComponent implements OnInit {
similarBookRecommendation: false,
};
coverCroppingSettings: CoverCroppingSettings = {
verticalCroppingEnabled: false,
horizontalCroppingEnabled: false,
aspectRatioThreshold: 2.5,
smartCroppingEnabled: false
};
private appSettingsService = inject(AppSettingsService);
private bookService = inject(BookService);
private messageService = inject(MessageService);
@@ -49,6 +58,9 @@ export class GlobalPreferencesComponent implements OnInit {
if (settings?.maxFileUploadSizeInMb) {
this.maxFileUploadSizeInMb = settings.maxFileUploadSizeInMb;
}
if (settings?.coverCroppingSettings) {
this.coverCroppingSettings = {...settings.coverCroppingSettings};
}
this.toggles.autoBookSearch = settings.autoBookSearch ?? false;
this.toggles.similarBookRecommendation = settings.similarBookRecommendation ?? false;
});
@@ -68,6 +80,10 @@ export class GlobalPreferencesComponent implements OnInit {
}
}
onCoverCroppingChange(): void {
this.saveSetting(AppSettingKey.COVER_CROPPING_SETTINGS, this.coverCroppingSettings);
}
saveCacheSize(): void {
if (!this.cbxCacheValue || this.cbxCacheValue <= 0) {
this.showMessage('error', 'Invalid Input', 'Please enter a valid cache size in MB.');

View File

@@ -108,6 +108,13 @@ export interface KoboSettings {
forceEnableHyphenation: boolean;
}
export interface CoverCroppingSettings {
verticalCroppingEnabled: boolean;
horizontalCroppingEnabled: boolean;
aspectRatioThreshold: number;
smartCroppingEnabled: boolean;
}
export interface AppSettings {
autoBookSearch: boolean;
similarBookRecommendation: boolean;
@@ -126,6 +133,7 @@ export interface AppSettings {
metadataPersistenceSettings: MetadataPersistenceSettings;
metadataPublicReviewsSettings: PublicReviewSettings;
koboSettings: KoboSettings;
coverCroppingSettings: CoverCroppingSettings;
metadataDownloadOnBookdrop: boolean;
}
@@ -146,5 +154,6 @@ export enum AppSettingKey {
METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS',
METADATA_DOWNLOAD_ON_BOOKDROP = 'METADATA_DOWNLOAD_ON_BOOKDROP',
METADATA_PUBLIC_REVIEWS_SETTINGS = 'METADATA_PUBLIC_REVIEWS_SETTINGS',
KOBO_SETTINGS = 'KOBO_SETTINGS'
KOBO_SETTINGS = 'KOBO_SETTINGS',
COVER_CROPPING_SETTINGS = 'COVER_CROPPING_SETTINGS'
}