mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -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);
|
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.")
|
@Operation(summary = "Recalculate metadata match scores", description = "Recalculate match scores for all metadata. Requires admin.")
|
||||||
@ApiResponse(responseCode = "204", description = "Match scores recalculated successfully")
|
@ApiResponse(responseCode = "204", description = "Match scores recalculated successfully")
|
||||||
@PostMapping("/metadata/recalculate-match-scores")
|
@PostMapping("/metadata/recalculate-match-scores")
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public enum ApiError {
|
|||||||
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),
|
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),
|
||||||
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
|
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
|
||||||
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %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 HttpStatus status;
|
||||||
private final String message;
|
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_PERSISTENCE_SETTINGS("metadata_persistence_settings", true, false),
|
||||||
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
|
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
|
||||||
KOBO_SETTINGS("kobo_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),
|
AUTO_BOOK_SEARCH("auto_book_search", false, false),
|
||||||
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),
|
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ public class AppSettings {
|
|||||||
private MetadataPersistenceSettings metadataPersistenceSettings;
|
private MetadataPersistenceSettings metadataPersistenceSettings;
|
||||||
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
|
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
|
||||||
private KoboSettings koboSettings;
|
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 com.adityachandel.booklore.model.entity.*;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface BookMetadataRepository extends JpaRepository<BookMetadataEntity, Long> {
|
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")
|
@Query("SELECT m FROM BookMetadataEntity m WHERE m.bookId IN :bookIds")
|
||||||
List<BookMetadataEntity> getMetadataForBookIds(@Param("bookIds") List<Long> 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> findAllByAuthorsContaining(AuthorEntity author);
|
||||||
|
|
||||||
List<BookMetadataEntity> findAllByCategoriesContaining(CategoryEntity category);
|
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.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.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.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.autoBookSearch(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.AUTO_BOOK_SEARCH, "true")));
|
||||||
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));
|
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));
|
||||||
|
|||||||
@@ -259,4 +259,13 @@ public class SettingPersistenceHelper {
|
|||||||
.forceEnableHyphenation(false)
|
.forceEnableHyphenation(false)
|
||||||
.build();
|
.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 {
|
try {
|
||||||
boolean saved = fileService.saveCoverImages(image, bookEntity.getId());
|
boolean saved = fileService.saveCoverImages(image, bookEntity.getId());
|
||||||
if (saved) {
|
if (saved) {
|
||||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
|
||||||
bookMetadataRepository.save(bookEntity.getMetadata());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
|
|||||||
originalImage.flush();
|
originalImage.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saved) {
|
|
||||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
|
||||||
bookMetadataRepository.save(bookEntity.getMetadata());
|
|
||||||
}
|
|
||||||
return saved;
|
return saved;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean saved = saveCoverImage(coverData, bookEntity.getId());
|
boolean saved = saveCoverImage(coverData, bookEntity.getId());
|
||||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
|
||||||
bookMetadataRepository.save(bookEntity.getMetadata());
|
|
||||||
return saved;
|
return saved;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
|||||||
@Override
|
@Override
|
||||||
public boolean generateCover(BookEntity bookEntity) {
|
public boolean generateCover(BookEntity bookEntity) {
|
||||||
try (PDDocument pdf = Loader.loadPDF(new File(FileUtils.getBookFullPath(bookEntity)))) {
|
try (PDDocument pdf = Loader.loadPDF(new File(FileUtils.getBookFullPath(bookEntity)))) {
|
||||||
boolean saved = generateCoverImageAndSave(bookEntity.getId(), pdf);
|
return generateCoverImageAndSave(bookEntity.getId(), pdf);
|
||||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
|
||||||
bookMetadataRepository.save(bookEntity.getMetadata());
|
|
||||||
return saved;
|
|
||||||
} catch (OutOfMemoryError e) {
|
} catch (OutOfMemoryError e) {
|
||||||
// Note: Catching OOM is generally discouraged, but for batch processing
|
// Note: Catching OOM is generally discouraged, but for batch processing
|
||||||
// of potentially large/corrupted PDFs, we prefer graceful degradation
|
// of potentially large/corrupted PDFs, we prefer graceful degradation
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -57,6 +58,8 @@ import java.util.stream.Collectors;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class BookMetadataService {
|
public class BookMetadataService {
|
||||||
|
|
||||||
|
private static final int BATCH_SIZE = 100;
|
||||||
|
|
||||||
private final BookRepository bookRepository;
|
private final BookRepository bookRepository;
|
||||||
private final BookMapper bookMapper;
|
private final BookMapper bookMapper;
|
||||||
private final BookMetadataMapper bookMetadataMapper;
|
private final BookMetadataMapper bookMetadataMapper;
|
||||||
@@ -157,6 +160,76 @@ public class BookMetadataService {
|
|||||||
return updateCover(bookId, (writer, book) -> writer.replaceCoverImageFromUpload(book, file));
|
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
|
@Transactional
|
||||||
public BookMetadata updateCoverImageFromUrl(Long bookId, String url) {
|
public BookMetadata updateCoverImageFromUrl(Long bookId, String url) {
|
||||||
fileService.createThumbnailFromUrl(bookId, 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() {
|
public void regenerateCovers() {
|
||||||
SecurityContextVirtualThread.runWithSecurityContext(() -> {
|
SecurityContextVirtualThread.runWithSecurityContext(() -> {
|
||||||
try {
|
try {
|
||||||
List<BookEntity> books = bookQueryService.getAllFullBookEntities().stream()
|
List<BookEntity> books = bookQueryService.getAllFullBookEntities().stream()
|
||||||
.filter(book -> book.getMetadata().getCoverLocked() == null || !book.getMetadata().getCoverLocked())
|
.filter(book -> !isCoverLocked(book))
|
||||||
.toList();
|
.toList();
|
||||||
int total = books.size();
|
int total = books.size();
|
||||||
notificationService.sendMessage(Topic.LOG, LogNotification.info("Started regenerating covers for " + total + " books"));
|
notificationService.sendMessage(Topic.LOG, LogNotification.info("Started regenerating covers for " + total + " books"));
|
||||||
|
|
||||||
int[] current = {1};
|
int current = 1;
|
||||||
for (BookEntity book : books) {
|
for (BookEntity book : books) {
|
||||||
try {
|
try {
|
||||||
String progress = "(" + current[0] + "/" + total + ") ";
|
String progress = "(" + current + "/" + total + ") ";
|
||||||
regenerateCoverForBook(book, progress);
|
regenerateCoverForBook(book, progress);
|
||||||
} catch (Exception e) {
|
} 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"));
|
notificationService.sendMessage(Topic.LOG, LogNotification.info("Finished regenerating covers"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -219,8 +351,7 @@ public class BookMetadataService {
|
|||||||
|
|
||||||
private void regenerateCoverForBook(BookEntity book, String progress) {
|
private void regenerateCoverForBook(BookEntity book, String progress) {
|
||||||
String title = book.getMetadata().getTitle();
|
String title = book.getMetadata().getTitle();
|
||||||
String message = progress + "Regenerating cover for: " + title;
|
notificationService.sendMessage(Topic.LOG, LogNotification.info(progress + "Regenerating cover for: " + title));
|
||||||
notificationService.sendMessage(Topic.LOG, LogNotification.info(message));
|
|
||||||
|
|
||||||
BookFileProcessor processor = processorRegistry.getProcessorOrThrow(book.getBookType());
|
BookFileProcessor processor = processorRegistry.getProcessorOrThrow(book.getBookType());
|
||||||
processor.generateCover(book);
|
processor.generateCover(book);
|
||||||
|
|||||||
@@ -357,7 +357,6 @@ public class BookMetadataUpdater {
|
|||||||
if (!set) return;
|
if (!set) return;
|
||||||
if (!StringUtils.hasText(m.getThumbnailUrl()) || isLocalOrPrivateUrl(m.getThumbnailUrl())) return;
|
if (!StringUtils.hasText(m.getThumbnailUrl()) || isLocalOrPrivateUrl(m.getThumbnailUrl())) return;
|
||||||
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||||
e.setCoverUpdatedOn(Instant.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateLocks(BookMetadata m, BookMetadataEntity e) {
|
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.config.AppProperties;
|
||||||
import com.adityachandel.booklore.exception.ApiError;
|
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.model.entity.BookMetadataEntity;
|
||||||
|
import com.adityachandel.booklore.repository.BookMetadataRepository;
|
||||||
|
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
@@ -38,6 +41,12 @@ public class FileService {
|
|||||||
|
|
||||||
private final AppProperties appProperties;
|
private final AppProperties appProperties;
|
||||||
private final RestTemplate restTemplate;
|
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
|
// @formatter:off
|
||||||
private static final String IMAGES_DIR = "images";
|
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) {
|
public void createThumbnailFromUrl(long bookId, String imageUrl) {
|
||||||
try {
|
try {
|
||||||
BufferedImage originalImage = downloadImageFromUrl(imageUrl);
|
BufferedImage originalImage = downloadImageFromUrl(imageUrl);
|
||||||
@@ -241,6 +271,7 @@ public class FileService {
|
|||||||
|
|
||||||
public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException {
|
public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException {
|
||||||
BufferedImage rgbImage = null;
|
BufferedImage rgbImage = null;
|
||||||
|
BufferedImage cropped = null;
|
||||||
BufferedImage resized = null;
|
BufferedImage resized = null;
|
||||||
BufferedImage thumb = null;
|
BufferedImage thumb = null;
|
||||||
try {
|
try {
|
||||||
@@ -260,6 +291,12 @@ public class FileService {
|
|||||||
g.dispose();
|
g.dispose();
|
||||||
// Note: coverImage is not flushed here - caller is responsible for its lifecycle
|
// 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
|
// Resize original image if too large to prevent OOM
|
||||||
double scale = Math.min(
|
double scale = Math.min(
|
||||||
(double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(),
|
(double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(),
|
||||||
@@ -278,13 +315,19 @@ public class FileService {
|
|||||||
File thumbnailFile = new File(folder, THUMBNAIL_FILENAME);
|
File thumbnailFile = new File(folder, THUMBNAIL_FILENAME);
|
||||||
boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile);
|
boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile);
|
||||||
|
|
||||||
|
if (originalSaved && thumbnailSaved) {
|
||||||
|
bookMetadataRepository.updateCoverTimestamp(bookId, Instant.now());
|
||||||
|
}
|
||||||
return originalSaved && thumbnailSaved;
|
return originalSaved && thumbnailSaved;
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup resources created within this method
|
// 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) {
|
if (rgbImage != null) {
|
||||||
rgbImage.flush();
|
rgbImage.flush();
|
||||||
}
|
}
|
||||||
|
if (cropped != null && cropped != rgbImage) {
|
||||||
|
cropped.flush();
|
||||||
|
}
|
||||||
if (resized != null && resized != rgbImage) {
|
if (resized != null && resized != rgbImage) {
|
||||||
resized.flush();
|
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) {
|
public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
|
||||||
bookMetadataEntity.setCoverUpdatedOn(Instant.now());
|
bookMetadataEntity.setCoverUpdatedOn(Instant.now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.adityachandel.booklore.util;
|
package com.adityachandel.booklore.util;
|
||||||
|
|
||||||
import com.adityachandel.booklore.config.AppProperties;
|
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.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.*;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -41,6 +45,9 @@ class FileServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private AppProperties appProperties;
|
private AppProperties appProperties;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AppSettingService appSettingService;
|
||||||
|
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
|
|
||||||
@TempDir
|
@TempDir
|
||||||
@@ -48,7 +55,17 @@ class FileServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
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
|
@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
|
@Nested
|
||||||
@DisplayName("createThumbnailFromFile")
|
@DisplayName("createThumbnailFromFile")
|
||||||
class CreateThumbnailFromFileTests {
|
class CreateThumbnailFromFileTests {
|
||||||
@@ -823,12 +950,26 @@ class FileServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private RestTemplate restTemplate;
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AppSettingService appSettingServiceForNetwork;
|
||||||
|
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() {
|
||||||
lenient().when(appProperties.getPathConfig()).thenReturn(tempDir.toString());
|
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
|
@Nested
|
||||||
@@ -844,7 +985,8 @@ class FileServiceTest {
|
|||||||
byte[] imageBytes = imageToBytes(testImage);
|
byte[] imageBytes = imageToBytes(testImage);
|
||||||
|
|
||||||
RestTemplate mockRestTemplate = mock(RestTemplate.class);
|
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);
|
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(imageBytes);
|
||||||
when(mockRestTemplate.exchange(
|
when(mockRestTemplate.exchange(
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
|||||||
() => this.fetchMetadata(),
|
() => this.fetchMetadata(),
|
||||||
() => this.bulkEditMetadata(),
|
() => this.bulkEditMetadata(),
|
||||||
() => this.multiBookEditMetadata(),
|
() => this.multiBookEditMetadata(),
|
||||||
|
() => this.regenerateCoversForSelected(),
|
||||||
);
|
);
|
||||||
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
|
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
|
||||||
|
|
||||||
@@ -668,6 +669,38 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
|||||||
this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks);
|
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() {
|
moveFiles() {
|
||||||
this.dialogHelperService.openFileMoverDialog(this.selectedBooks);
|
this.dialogHelperService.openFileMoverDialog(this.selectedBooks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export class BookMenuService {
|
|||||||
autoFetchMetadata: () => void,
|
autoFetchMetadata: () => void,
|
||||||
fetchMetadata: () => void,
|
fetchMetadata: () => void,
|
||||||
bulkEditMetadata: () => void,
|
bulkEditMetadata: () => void,
|
||||||
multiBookEditMetadata: () => void): MenuItem[] {
|
multiBookEditMetadata: () => void,
|
||||||
|
regenerateCovers: () => void): MenuItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Auto Fetch Metadata',
|
label: 'Auto Fetch Metadata',
|
||||||
@@ -43,6 +44,11 @@ export class BookMenuService {
|
|||||||
label: 'Multi-Book Metadata Editor',
|
label: 'Multi-Book Metadata Editor',
|
||||||
icon: 'pi pi-clone',
|
icon: 'pi pi-clone',
|
||||||
command: multiBookEditMetadata
|
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`, {});
|
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 ------------------*/
|
/*------------------ All the metadata related calls go here ------------------*/
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex justify-end mt-4">
|
||||||
<p-button type="submit" label="Apply to Selected" [disabled]="loading"/>
|
<p-button type="submit" label="Apply to Selected" [disabled]="loading"/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class BulkMetadataUpdateComponent implements OnInit {
|
|||||||
mergeMoods = true;
|
mergeMoods = true;
|
||||||
mergeTags = true;
|
mergeTags = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
selectedCoverFile: File | null = null;
|
||||||
|
|
||||||
clearFields = {
|
clearFields = {
|
||||||
authors: false,
|
authors: false,
|
||||||
@@ -257,13 +258,37 @@ export class BulkMetadataUpdateComponent implements OnInit {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.bookService.updateBooksMetadata(payload).subscribe({
|
this.bookService.updateBooksMetadata(payload).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loading = false;
|
if (this.selectedCoverFile) {
|
||||||
this.messageService.add({
|
this.bookService.bulkUploadCover(this.bookIds, this.selectedCoverFile).subscribe({
|
||||||
severity: 'success',
|
next: () => {
|
||||||
summary: 'Metadata Updated',
|
this.loading = false;
|
||||||
detail: 'Books updated successfully'
|
this.messageService.add({
|
||||||
});
|
severity: 'success',
|
||||||
this.ref.close(true);
|
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 => {
|
error: err => {
|
||||||
console.error('Bulk metadata update failed:', 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>
|
</div>
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
<i class="pi pi-info-circle"></i>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -215,3 +215,14 @@
|
|||||||
margin-top: 0.5rem;
|
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 {AppSettingsService} from '../../../shared/service/app-settings.service';
|
||||||
import {BookService} from '../../book/service/book.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 {filter, take} from 'rxjs/operators';
|
||||||
import {InputText} from 'primeng/inputtext';
|
import {InputText} from 'primeng/inputtext';
|
||||||
|
import {Slider} from 'primeng/slider';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-global-preferences',
|
selector: 'app-global-preferences',
|
||||||
@@ -18,7 +19,8 @@ import {InputText} from 'primeng/inputtext';
|
|||||||
Button,
|
Button,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
InputText
|
InputText,
|
||||||
|
Slider
|
||||||
],
|
],
|
||||||
templateUrl: './global-preferences.component.html',
|
templateUrl: './global-preferences.component.html',
|
||||||
styleUrl: './global-preferences.component.scss'
|
styleUrl: './global-preferences.component.scss'
|
||||||
@@ -30,6 +32,13 @@ export class GlobalPreferencesComponent implements OnInit {
|
|||||||
similarBookRecommendation: false,
|
similarBookRecommendation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
coverCroppingSettings: CoverCroppingSettings = {
|
||||||
|
verticalCroppingEnabled: false,
|
||||||
|
horizontalCroppingEnabled: false,
|
||||||
|
aspectRatioThreshold: 2.5,
|
||||||
|
smartCroppingEnabled: false
|
||||||
|
};
|
||||||
|
|
||||||
private appSettingsService = inject(AppSettingsService);
|
private appSettingsService = inject(AppSettingsService);
|
||||||
private bookService = inject(BookService);
|
private bookService = inject(BookService);
|
||||||
private messageService = inject(MessageService);
|
private messageService = inject(MessageService);
|
||||||
@@ -49,6 +58,9 @@ export class GlobalPreferencesComponent implements OnInit {
|
|||||||
if (settings?.maxFileUploadSizeInMb) {
|
if (settings?.maxFileUploadSizeInMb) {
|
||||||
this.maxFileUploadSizeInMb = settings.maxFileUploadSizeInMb;
|
this.maxFileUploadSizeInMb = settings.maxFileUploadSizeInMb;
|
||||||
}
|
}
|
||||||
|
if (settings?.coverCroppingSettings) {
|
||||||
|
this.coverCroppingSettings = {...settings.coverCroppingSettings};
|
||||||
|
}
|
||||||
this.toggles.autoBookSearch = settings.autoBookSearch ?? false;
|
this.toggles.autoBookSearch = settings.autoBookSearch ?? false;
|
||||||
this.toggles.similarBookRecommendation = settings.similarBookRecommendation ?? 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 {
|
saveCacheSize(): void {
|
||||||
if (!this.cbxCacheValue || this.cbxCacheValue <= 0) {
|
if (!this.cbxCacheValue || this.cbxCacheValue <= 0) {
|
||||||
this.showMessage('error', 'Invalid Input', 'Please enter a valid cache size in MB.');
|
this.showMessage('error', 'Invalid Input', 'Please enter a valid cache size in MB.');
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ export interface KoboSettings {
|
|||||||
forceEnableHyphenation: boolean;
|
forceEnableHyphenation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoverCroppingSettings {
|
||||||
|
verticalCroppingEnabled: boolean;
|
||||||
|
horizontalCroppingEnabled: boolean;
|
||||||
|
aspectRatioThreshold: number;
|
||||||
|
smartCroppingEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
autoBookSearch: boolean;
|
autoBookSearch: boolean;
|
||||||
similarBookRecommendation: boolean;
|
similarBookRecommendation: boolean;
|
||||||
@@ -126,6 +133,7 @@ export interface AppSettings {
|
|||||||
metadataPersistenceSettings: MetadataPersistenceSettings;
|
metadataPersistenceSettings: MetadataPersistenceSettings;
|
||||||
metadataPublicReviewsSettings: PublicReviewSettings;
|
metadataPublicReviewsSettings: PublicReviewSettings;
|
||||||
koboSettings: KoboSettings;
|
koboSettings: KoboSettings;
|
||||||
|
coverCroppingSettings: CoverCroppingSettings;
|
||||||
metadataDownloadOnBookdrop: boolean;
|
metadataDownloadOnBookdrop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,5 +154,6 @@ export enum AppSettingKey {
|
|||||||
METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS',
|
METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS',
|
||||||
METADATA_DOWNLOAD_ON_BOOKDROP = 'METADATA_DOWNLOAD_ON_BOOKDROP',
|
METADATA_DOWNLOAD_ON_BOOKDROP = 'METADATA_DOWNLOAD_ON_BOOKDROP',
|
||||||
METADATA_PUBLIC_REVIEWS_SETTINGS = 'METADATA_PUBLIC_REVIEWS_SETTINGS',
|
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