mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 14:20:48 -05:00
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:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -33,4 +33,5 @@ public class AppSettings {
|
||||
private MetadataPersistenceSettings metadataPersistenceSettings;
|
||||
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
|
||||
private KoboSettings koboSettings;
|
||||
private CoverCroppingSettings coverCroppingSettings;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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})>"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 ------------------*/
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user