From 54108754f9ac36b91d6f932841e0ed7701cdca7d Mon Sep 17 00:00:00 2001 From: CounterClops <19741838+CounterClops@users.noreply.github.com> Date: Sat, 20 Dec 2025 02:20:50 +0800 Subject: [PATCH] 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 --- .../controller/MetadataController.java | 21 +++ .../booklore/exception/ApiError.java | 2 +- .../model/dto/request/BulkBookIdsRequest.java | 12 ++ .../model/dto/settings/AppSettingKey.java | 1 + .../model/dto/settings/AppSettings.java | 1 + .../dto/settings/CoverCroppingSettings.java | 13 ++ .../repository/BookMetadataRepository.java | 8 + .../appsettings/AppSettingService.java | 1 + .../appsettings/SettingPersistenceHelper.java | 9 ++ .../service/fileprocessor/CbxProcessor.java | 2 - .../service/fileprocessor/EpubProcessor.java | 4 - .../service/fileprocessor/Fb2Processor.java | 2 - .../service/fileprocessor/PdfProcessor.java | 5 +- .../service/metadata/BookMetadataService.java | 145 ++++++++++++++++- .../service/metadata/BookMetadataUpdater.java | 1 - .../booklore/util/FileService.java | 149 +++++++++++++++++- .../booklore/util/FileServiceTest.java | 148 ++++++++++++++++- .../book-browser/book-browser.component.ts | 33 ++++ .../book/service/book-menu.service.ts | 8 +- .../app/features/book/service/book.service.ts | 11 ++ .../bulk-metadata-update-component.html | 43 +++++ .../bulk-metadata-update-component.ts | 50 +++++- .../global-preferences.component.html | 71 ++++++++- .../global-preferences.component.scss | 11 ++ .../global-preferences.component.ts | 20 ++- .../app/shared/model/app-settings.model.ts | 11 +- 26 files changed, 745 insertions(+), 37 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkBookIdsRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/CoverCroppingSettings.java diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java index c484e368..ad0c35d1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java @@ -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 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 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 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") diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index 1ecca03e..f4ed7862 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkBookIdsRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkBookIdsRequest.java new file mode 100644 index 00000000..eb8757d4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkBookIdsRequest.java @@ -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 bookIds; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java index 35cee670..7add6c81 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java @@ -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), diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index 792b6b3d..1e2c650a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -33,4 +33,5 @@ public class AppSettings { private MetadataPersistenceSettings metadataPersistenceSettings; private MetadataPublicReviewsSettings metadataPublicReviewsSettings; private KoboSettings koboSettings; + private CoverCroppingSettings coverCroppingSettings; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/CoverCroppingSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/CoverCroppingSettings.java new file mode 100644 index 00000000..d5e7fc20 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/CoverCroppingSettings.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMetadataRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMetadataRepository.java index 3ef971e8..2624c656 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMetadataRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMetadataRepository.java @@ -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 { @@ -12,6 +15,11 @@ public interface BookMetadataRepository extends JpaRepository getMetadataForBookIds(@Param("bookIds") List 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 findAllByAuthorsContaining(AuthorEntity author); List findAllByCategoriesContaining(CategoryEntity category); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java index 7ee03667..67415c22 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java @@ -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})>")); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index d1a0a6b1..cdbfbf0c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -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(); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java index 817eb4ce..b99e7447 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java @@ -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 { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java index e710bd2f..c9c9a0fc 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java @@ -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) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java index c166b6be..0bb51e0c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java @@ -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) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java index f747cedb..d400eac4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java @@ -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 diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index f526aa90..1ad87ef5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -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 bookIds, MultipartFile file) { + validateCoverFile(file); + byte[] coverImageBytes = extractBytesFromMultipartFile(file); + List 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 getUnlockedBookCoverInfos(Set 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 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 bookIds) { + List unlockedBooks = getUnlockedBookRegenerationInfos(bookIds); + SecurityContextVirtualThread.runWithSecurityContext(() -> + processBulkCoverRegeneration(unlockedBooks)); + } + + private List getUnlockedBookRegenerationInfos(Set 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 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 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); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index 451156c3..fca899ff 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -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) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index f97bdacc..92812f01 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -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()); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java index f47d0ece..c370c131 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java @@ -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 responseEntity = ResponseEntity.ok(imageBytes); when(mockRestTemplate.exchange( diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts index 262101a4..04c4dfd4 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts @@ -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); } diff --git a/booklore-ui/src/app/features/book/service/book-menu.service.ts b/booklore-ui/src/app/features/book/service/book-menu.service.ts index 23d85f89..d0f14b94 100644 --- a/booklore-ui/src/app/features/book/service/book-menu.service.ts +++ b/booklore-ui/src/app/features/book/service/book-menu.service.ts @@ -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 } ]; } diff --git a/booklore-ui/src/app/features/book/service/book.service.ts b/booklore-ui/src/app/features/book/service/book.service.ts index bd4c4b70..d9306851 100644 --- a/booklore-ui/src/app/features/book/service/book.service.ts +++ b/booklore-ui/src/app/features/book/service/book.service.ts @@ -432,6 +432,17 @@ export class BookService { return this.http.post(`${this.url}/${bookId}/regenerate-cover`, {}); } + regenerateCoversForBooks(bookIds: number[]): Observable { + return this.http.post(`${this.url}/bulk-regenerate-covers`, { bookIds }); + } + + bulkUploadCover(bookIds: number[], file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + formData.append('bookIds', bookIds.join(',')); + return this.http.post(`${this.url}/bulk-upload-cover`, formData); + } + /*------------------ All the metadata related calls go here ------------------*/ diff --git a/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.html b/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.html index 43e92f88..70faaa90 100644 --- a/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.html +++ b/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.html @@ -312,6 +312,49 @@ + +
+ +

+ Upload an image to set as the cover for all selected books. +

+
+ @if (selectedCoverFile) { +
+ + {{ selectedCoverFile.name }} + + +
+ } @else { + + + + } +
+
+
diff --git a/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.ts b/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.ts index cf2e9a87..d8d4383d 100644 --- a/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.ts +++ b/booklore-ui/src/app/features/metadata/component/bulk-metadata-update/bulk-metadata-update-component.ts @@ -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; + } } diff --git a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.html b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.html index 01faa5a1..11c791e3 100644 --- a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.html +++ b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.html @@ -34,7 +34,76 @@

- 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. +

+ + + +
+
+
+ + + +
+

+ + Automatically crop extremely tall images (like web comics) from the top to create usable cover thumbnails. +

+
+
+ +
+
+
+ + + +
+

+ + Automatically crop extremely wide images from the left to create usable cover thumbnails. +

+
+
+ +
+
+
+ +
+ + +
+
+

+ + 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. +

+
+
+ +
+
+
+ + + +
+

+ + Skip uniform colour regions when determining where to crop. Focuses the cover image on the most relevant content.

diff --git a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.scss b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.scss index 191e9ce6..cb4f9a15 100644 --- a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.scss +++ b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.scss @@ -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; + } +} diff --git a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.ts b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.ts index 5009797a..672cf9bd 100644 --- a/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.ts +++ b/booklore-ui/src/app/features/settings/global-preferences/global-preferences.component.ts @@ -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.'); diff --git a/booklore-ui/src/app/shared/model/app-settings.model.ts b/booklore-ui/src/app/shared/model/app-settings.model.ts index 13168f50..e91ca2d9 100644 --- a/booklore-ui/src/app/shared/model/app-settings.model.ts +++ b/booklore-ui/src/app/shared/model/app-settings.model.ts @@ -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' }