From 177528e640b30175f407c8ea3f53f670a0fbedfb Mon Sep 17 00:00:00 2001 From: "aditya.chandel" Date: Fri, 18 Jul 2025 18:36:52 -0600 Subject: [PATCH] Implement Bookdrop: Watch folder for file drops and auto-process uploads --- README.md | 45 ++- booklore-api/build.gradle | 1 + .../booklore/config/AppProperties.java | 1 + .../config/security/SecurityConfig.java | 3 +- .../controller/BookdropFileController.java | 80 ++++ .../booklore/exception/ApiError.java | 3 +- .../booklore/mapper/BookdropFileMapper.java | 22 ++ .../booklore/mapper/JsonMetadataMapper.java | 27 ++ .../booklore/model/BookDropFileEvent.java | 18 + .../booklore/model/dto/BookdropFile.java | 17 + .../model/dto/BookdropFileNotification.java | 14 + .../dto/request/BookdropFinalizeRequest.java | 20 + .../dto/response/BookdropFileResult.java | 12 + .../dto/response/BookdropFinalizeResult.java | 14 + .../model/dto/settings/AppSettingKey.java | 1 + .../model/dto/settings/AppSettings.java | 1 + .../model/entity/BookdropFileEntity.java | 56 +++ .../model/websocket/LogNotification.java | 2 +- .../booklore/model/websocket/Topic.java | 1 + .../repository/BookdropFileRepository.java | 29 ++ .../appsettings/AppSettingService.java | 3 +- .../service/bookdrop/BookDropService.java | 250 +++++++++++++ .../bookdrop/BookdropEventHandlerService.java | 138 +++++++ .../bookdrop/BookdropMetadataService.java | 128 +++++++ .../bookdrop/BookdropMonitoringService.java | 194 ++++++++++ .../bookdrop/BookdropNotificationService.java | 31 ++ .../service/metadata/BookMetadataService.java | 5 +- .../metadata/MetadataRefreshService.java | 45 ++- .../extractor/CbxMetadataExtractor.java | 58 +++ .../extractor/EpubMetadataExtractor.java | 35 +- .../extractor/FileMetadataExtractor.java | 2 + .../extractor/PdfMetadataExtractor.java | 22 ++ .../service/monitoring/MonitoringService.java | 6 +- .../booklore/util/FileService.java | 27 +- .../booklore/util/ImageUtils.java | 37 ++ .../src/main/resources/application.yaml | 1 + .../V38__Create_bookdrop_file_table.sql | 13 + .../booklore/service/BookDropServiceTest.java | 127 +++++++ .../service/BookdropMetadataServiceTest.java | 183 +++++++++ .../BookdropNotificationServiceTest.java | 61 +++ booklore-ui/src/app/app.component.ts | 7 + booklore-ui/src/app/app.routes.ts | 4 +- .../book-browser/BookDialogHelperService.ts | 2 +- .../book-card-lite-component.ts | 2 +- .../src/app/book/service/metadata-task.ts | 4 +- .../app/bookdrop/bookdrop-file-api.service.ts | 18 + ...okdrop-file-metadata-picker.component.html | 245 ++++++++++++ ...okdrop-file-metadata-picker.component.scss | 31 ++ ...bookdrop-file-metadata-picker.component.ts | 168 +++++++++ .../bookdrop-file-review.component.html | 270 +++++++++++++ .../bookdrop-file-review.component.scss | 12 + .../bookdrop-file-review.component.ts | 354 ++++++++++++++++++ .../bookdrop/bookdrop-file-task.service.ts | 64 ++++ .../src/app/bookdrop/bookdrop-file.service.ts | 54 +++ .../bookdrop-files-widget.component.html | 23 ++ .../bookdrop-files-widget.component.scss | 5 + .../bookdrop-files-widget.component.ts | 44 +++ ...drop-finalize-result-dialog-component.html | 26 ++ ...rop-finalize-result-dialog-component.scss} | 0 ...okdrop-finalize-result-dialog-component.ts | 25 ++ .../live-notification-box.component.html | 2 +- ...nified-notification-popover-component.html | 4 +- .../unified-notification-popover-component.ts | 8 +- .../src/app/core/model/app-settings.model.ts | 3 +- .../layout-topbar/app.topbar.component.ts | 101 +++-- .../book-metadata-center.component.ts | 2 +- .../metadata-review-dialog-component.ts | 8 +- .../metadata-match-weights-component.ts | 2 +- .../metadata-settings-component.html | 24 ++ .../metadata-settings-component.ts | 42 ++- .../file-mover-component.html | 0 .../file-mover-component.scss | 0 .../file-mover-component.ts | 12 +- .../github-support-dialog.html | 0 .../github-support-dialog.scss | 0 .../github-support-dialog.ts | 0 .../service}/book-metadata-host-service.ts | 0 .../service}/file-operations-service.ts | 2 +- .../metadata-match-weights-service.ts | 2 +- .../utilities/service/url-helper.service.ts | 4 + 80 files changed, 3171 insertions(+), 136 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookdropFileMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/JsonMetadataMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/BookDropFileEvent.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFile.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFileNotification.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookdropFinalizeRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFileResult.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFinalizeResult.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookdropFileEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMetadataService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/util/ImageUtils.java create mode 100644 booklore-api/src/main/resources/db/migration/V38__Create_bookdrop_file_table.sql create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/BookDropServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropMetadataServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropNotificationServiceTest.java create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.html create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.scss create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.html create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.scss create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-file.service.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.html create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.scss create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.ts create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.html rename booklore-ui/src/app/{github-support-dialog/github-support-dialog.scss => bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.scss} (100%) create mode 100644 booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.ts rename booklore-ui/src/app/{ => utilities/component}/file-mover-component/file-mover-component.html (100%) rename booklore-ui/src/app/{ => utilities/component}/file-mover-component/file-mover-component.scss (100%) rename booklore-ui/src/app/{ => utilities/component}/file-mover-component/file-mover-component.ts (94%) rename booklore-ui/src/app/{ => utilities/component}/github-support-dialog/github-support-dialog.html (100%) create mode 100644 booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.scss rename booklore-ui/src/app/{ => utilities/component}/github-support-dialog/github-support-dialog.ts (100%) rename booklore-ui/src/app/{ => utilities/service}/book-metadata-host-service.ts (100%) rename booklore-ui/src/app/{ => utilities/service}/file-operations-service.ts (90%) rename booklore-ui/src/app/{ => utilities/service}/metadata-match-weights-service.ts (94%) diff --git a/README.md b/README.md index 9171d663..ae1a5d19 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ ## 🎥 Video Guides & Tutorials These videos cover deployment, configuration, and feature highlights to help you get started quickly. - ## 🐳 Deploy with Docker You can quickly set up and run BookLore using Docker. @@ -50,18 +49,20 @@ ### 2️⃣ Create docker-compose.yml - PUID=1000 - PGID=1000 - TZ=Etc/UTC - - DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup - - DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container - - DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container - - SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production). + - DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup + - DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container + - DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container + - SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production). depends_on: mariadb: condition: service_healthy ports: - "6060:6060" volumes: - - /your/local/path/to/booklore/data:/app/data - - /your/local/path/to/booklore/books:/books + - /your/local/path/to/booklore/data:/app/data # Internal app data (settings, metadata, cache) + - /your/local/path/to/booklore/books1:/books1 # Book library folder — point to one of your collections + - /your/local/path/to/booklore/books2:/books2 # Another book library — you can mount multiple library folders this way + - /your/local/path/to/booklore/bookdrop:/bookdrop # Bookdrop folder — drop new files here for automatic import into libraries restart: unless-stopped mariadb: @@ -71,10 +72,10 @@ ### 2️⃣ Create docker-compose.yml - PUID=1000 - PGID=1000 - TZ=Etc/UTC - - MYSQL_ROOT_PASSWORD=super_secure_password # Use a strong password for the database's root user, should be different from MYSQL_PASSWORD + - MYSQL_ROOT_PASSWORD=super_secure_password # Use a strong password for the database's root user, should be different from MYSQL_PASSWORD - MYSQL_DATABASE=booklore - - MYSQL_USER=booklore # Must match DATABASE_USERNAME defined in the booklore container - - MYSQL_PASSWORD=your_secure_password # Use a strong password; must match DATABASE_PASSWORD defined in the booklore container + - MYSQL_USER=booklore # Must match DATABASE_USERNAME defined in the booklore container + - MYSQL_PASSWORD=your_secure_password # Use a strong password; must match DATABASE_PASSWORD defined in the booklore container volumes: - /your/local/path/to/mariadb/config:/config restart: unless-stopped @@ -103,7 +104,31 @@ ### 4️⃣ Access BookLore ```ini http://localhost:6060 ``` +## 📥 Bookdrop Folder: Auto-Import Files (New) +BookLore now supports a **Bookdrop folder**, a special directory where you can drop your book files (`.pdf`, `.epub`, `.cbz`, etc.), and BookLore will automatically detect, process, and prepare them for import. This makes it easy to bulk add new books without manually uploading each one. + +### 🔍 How It Works + +1. **File Watcher:** A background process continuously monitors the Bookdrop folder. +2. **File Detection:** When new files are added, BookLore automatically reads them and extracts basic metadata (title, author, etc.) from filenames or embedded data. +3. **Optional Metadata Fetching:** If enabled, BookLore can query metadata sources like Google Books or Open Library to enrich the book information. +4. **Review & Finalize:** You can then review the detected books in the Bookdrop UI, edit metadata if needed, and assign each book to a library and folder structure before finalizing the import. + +### ⚙️ Configuration (Docker Setup) + +To enable the Bookdrop feature in Docker: + +```yaml +services: + booklore: + ... + volumes: + - /your/local/path/to/booklore/data:/app/data + - /your/local/path/to/booklore/books:/books + - /your/local/path/to/booklore/bookdrop:/bookdrop # 👈 Bookdrop directory +``` + ## 🔑 OIDC/OAuth2 Authentication (Authentik, Pocket ID, etc.) diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index 4d7c4d4d..9785f1d2 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -77,6 +77,7 @@ dependencies { // --- Test Dependencies --- testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.assertj:assertj-core:3.26.3' + testImplementation "org.mockito:mockito-inline:5.2.0" } hibernate { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java index 0759c563..c59acd55 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; @Setter public class AppProperties { private String pathConfig; + private String bookdropFolder; private String version; private RemoteAuth remoteAuth; private Swagger swagger = new Swagger(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index 59772e15..9a2edb01 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -53,7 +53,8 @@ public class SecurityConfig { "/api/v1/books/*/backup-cover", "/api/v1/opds/*/cover.jpg", "/api/v1/cbx/*/pages/*", - "/api/v1/pdf/*/pages/*" + "/api/v1/pdf/*/pages/*", + "/api/bookdrop/*/cover" }; private static final String[] COMMON_UNAUTHENTICATED_ENDPOINTS = { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java new file mode 100644 index 00000000..47bda9cd --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java @@ -0,0 +1,80 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.mapper.BookdropFileMapper; +import com.adityachandel.booklore.model.dto.BookdropFile; +import com.adityachandel.booklore.model.dto.BookdropFileNotification; +import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest; +import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.bookdrop.BookDropService; +import lombok.AllArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/bookdrop") +public class BookdropFileController { + + private final BookdropFileRepository repository; + private final BookdropFileMapper mapper; + private final BookDropService bookDropService; + + @GetMapping("/notification") + public BookdropFileNotification getSummary() { + long pendingCount = repository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); + long totalCount = repository.count(); + + return new BookdropFileNotification( + (int) pendingCount, + (int) totalCount, + Instant.now().toString() + ); + } + + @GetMapping("/files") + public List getFilesByStatus(@RequestParam(required = false) String status) { + if ("pending".equalsIgnoreCase(status)) { + return repository.findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW) + .stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + return repository.findAll() + .stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @DeleteMapping("/files") + public ResponseEntity discardAllFiles() { + bookDropService.discardAllFiles(); + return ResponseEntity.ok().build(); + } + + @PostMapping("/imports/finalize") + public ResponseEntity finalizeImport(@RequestBody BookdropFinalizeRequest request) { + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + return ResponseEntity.ok(result); + } + + @GetMapping("/{bookdropId}/cover") + public ResponseEntity getBookdropCover(@PathVariable long bookdropId) { + Resource file = bookDropService.getBookdropCover(bookdropId); + if (file == null) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=cover.jpg") + .contentType(MediaType.IMAGE_JPEG) + .body(file); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index 28cdd1fc..08955bd9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -46,7 +46,8 @@ public enum ApiError { SELF_DELETION_NOT_ALLOWED(HttpStatus.FORBIDDEN, "You cannot delete your own account"), INVALID_INPUT(HttpStatus.BAD_REQUEST, "%s"), FILE_DELETION_DISABLED(HttpStatus.BAD_REQUEST, "File deletion is disabled"), - UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "%s"); + UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "%s"), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"); private final HttpStatus status; private final String message; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookdropFileMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookdropFileMapper.java new file mode 100644 index 00000000..222f82c5 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookdropFileMapper.java @@ -0,0 +1,22 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.BookdropFile; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +@Mapper(componentModel = "spring") +public interface BookdropFileMapper { + + @Mapping(target = "originalMetadata", source = "originalMetadata", qualifiedByName = "jsonToBookMetadata") + @Mapping(target = "fetchedMetadata", source = "fetchedMetadata", qualifiedByName = "jsonToBookMetadata") + BookdropFile toDto(BookdropFileEntity entity); + + @Named("jsonToBookMetadata") + default BookMetadata jsonToBookMetadata(String json) { + if (json == null || json.isBlank()) return null; + return JsonMetadataMapper.parse(json); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/JsonMetadataMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/JsonMetadataMapper.java new file mode 100644 index 00000000..80cf0634 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/JsonMetadataMapper.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public class JsonMetadataMapper { + + private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + public static BookMetadata parse(String json) { + try { + return objectMapper.readValue(json, BookMetadata.class); + } catch (JsonProcessingException e) { + return null; + } + } + + public static String toJson(BookMetadata metadata) { + try { + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + return null; + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/BookDropFileEvent.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/BookDropFileEvent.java new file mode 100644 index 00000000..c8dbcad0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/BookDropFileEvent.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import java.nio.file.Path; +import java.nio.file.WatchEvent; + +@Getter +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor +public class BookDropFileEvent { + private final Path file; + private final WatchEvent.Kind kind; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFile.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFile.java new file mode 100644 index 00000000..46572449 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFile.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.model.dto; + +import com.adityachandel.booklore.model.entity.BookdropFileEntity.Status; +import lombok.Data; + +@Data +public class BookdropFile { + private Long id; + private String fileName; + private String filePath; + private Long fileSize; + private BookMetadata originalMetadata; + private BookMetadata fetchedMetadata; + private String createdAt; + private String updatedAt; + private Status status; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFileNotification.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFileNotification.java new file mode 100644 index 00000000..044ebbb8 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookdropFileNotification.java @@ -0,0 +1,14 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BookdropFileNotification { + private int pendingCount; + private int totalCount; + private String lastUpdatedAt; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookdropFinalizeRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookdropFinalizeRequest.java new file mode 100644 index 00000000..ab3c805b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookdropFinalizeRequest.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.model.dto.request; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import lombok.Data; + +import java.util.List; + +@Data +public class BookdropFinalizeRequest { + private String uploadPattern; + private List files; + + @Data + public static class BookdropFinalizeFile { + private Long fileId; + private Long libraryId; + private Long pathId; + private BookMetadata metadata; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFileResult.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFileResult.java new file mode 100644 index 00000000..8fc797cd --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFileResult.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BookdropFileResult { + private String fileName; + private boolean success; + private String message; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFinalizeResult.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFinalizeResult.java new file mode 100644 index 00000000..8b5f4945 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookdropFinalizeResult.java @@ -0,0 +1,14 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder +public class BookdropFinalizeResult { + @Builder.Default + private List results = new ArrayList<>(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java index 65fca0b9..d2144232 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java @@ -23,6 +23,7 @@ public enum AppSettingKey { CBX_CACHE_SIZE_IN_MB("cbx_cache_size_in_mb", false), PDF_CACHE_SIZE_IN_MB("pdf_cache_size_in_mb", false), BOOK_DELETION_ENABLED("book_deletion_enabled", false), + METADATA_DOWNLOAD_ON_BOOKDROP("metadata_download_on_bookdrop", false), MAX_FILE_UPLOAD_SIZE_IN_MB("max_file_upload_size_in_mb", false); private final String dbKey; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index 1b30c644..0fd7d53f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -24,6 +24,7 @@ public class AppSettings { private boolean remoteAuthEnabled; private boolean oidcEnabled; private boolean bookDeletionEnabled; + private boolean metadataDownloadOnBookdrop; private OidcProviderDetails oidcProviderDetails; private OidcAutoProvisionDetails oidcAutoProvisionDetails; private MetadataProviderSettings metadataProviderSettings; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookdropFileEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookdropFileEntity.java new file mode 100644 index 00000000..a5c9f4b1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookdropFileEntity.java @@ -0,0 +1,56 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; + +@Entity +@Table(name = "bookdrop_file", uniqueConstraints = {@UniqueConstraint(name = "uq_file_path", columnNames = {"file_path"})}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BookdropFileEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "file_path", columnDefinition = "TEXT", nullable = false) + private String filePath; + + @Column(name = "file_name", length = 512, nullable = false) + private String fileName; + + @Column(name = "file_size") + private Long fileSize; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private Status status = Status.PENDING_REVIEW; + + @Lob + @Column(name = "original_metadata", columnDefinition = "JSON") + private String originalMetadata; + + @Lob + @Column(name = "fetched_metadata", columnDefinition = "JSON") + private String fetchedMetadata; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + public enum Status { + PENDING_REVIEW, + FINALIZED + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/LogNotification.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/LogNotification.java index 7b27c4f3..6f18642b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/LogNotification.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/LogNotification.java @@ -11,7 +11,7 @@ public class LogNotification { private final Instant timestamp = Instant.now(); private final String message; - private LogNotification(String message) { + public LogNotification(String message) { this.message = message; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java index 9577a032..3ef47a9a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java @@ -9,6 +9,7 @@ public enum Topic { BOOK_METADATA_UPDATE("/topic/book-metadata-update"), BOOK_METADATA_BATCH_UPDATE("/topic/book-metadata-batch-update"), BOOK_METADATA_BATCH_PROGRESS("/topic/book-metadata-batch-progress"), + BOOKDROP_FILE("/topic/bookdrop-file"), LOG("/topic/log"); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java new file mode 100644 index 00000000..4aa9c426 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java @@ -0,0 +1,29 @@ +package com.adityachandel.booklore.repository; + + +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.entity.BookdropFileEntity.Status; +import jakarta.transaction.Transactional; +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.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BookdropFileRepository extends JpaRepository { + + Optional findByFilePath(String filePath); + + List findAllByStatus(Status status); + + long countByStatus(Status status); + + @Transactional + @Modifying + @Query("DELETE FROM BookdropFileEntity f WHERE f.filePath LIKE CONCAT(:prefix, '%')") + int deleteAllByFilePathStartingWith(@Param("prefix") String prefix); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java index 8bf1b535..c059df9b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java @@ -74,7 +74,7 @@ public class AppSettingService { builder.coverResolution(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.COVER_IMAGE_RESOLUTION, "250x350")); builder.autoBookSearch(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.AUTO_BOOK_SEARCH, "true"))); builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{currentFilename}")); - builder.movePattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MOVE_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title} - {authors}< ({year})>")); + builder.movePattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MOVE_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>")); builder.similarBookRecommendation(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.SIMILAR_BOOK_RECOMMENDATION, "true"))); builder.opdsServerEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OPDS_SERVER_ENABLED, "false"))); builder.oidcEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OIDC_ENABLED, "false"))); @@ -82,6 +82,7 @@ public class AppSettingService { builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120"))); builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100"))); builder.bookDeletionEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.BOOK_DELETION_ENABLED, "false"))); + builder.metadataDownloadOnBookdrop(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.METADATA_DOWNLOAD_ON_BOOKDROP, "true"))); return builder.build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java new file mode 100644 index 00000000..59a56ccb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java @@ -0,0 +1,250 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest; +import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult; +import com.adityachandel.booklore.model.dto.response.BookdropFileResult; +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.*; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.service.fileprocessor.*; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.adityachandel.booklore.service.monitoring.*; +import com.adityachandel.booklore.util.FileUtils; +import com.adityachandel.booklore.util.PathPatternResolver; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.*; +import java.util.Comparator; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +@Slf4j +@AllArgsConstructor +@Service +public class BookDropService { + + private final BookdropFileRepository bookdropFileRepository; + private final LibraryRepository libraryRepository; + private final BookRepository bookRepository; + private final MonitoringService monitoringService; + private final BookdropMonitoringService bookdropMonitoringService; + private final NotificationService notificationService; + private final MetadataRefreshService metadataRefreshService; + private final BookdropNotificationService bookdropNotificationService; + private final PdfProcessor pdfProcessor; + private final EpubProcessor epubProcessor; + private final CbxProcessor cbxProcessor; + private final AppProperties appProperties; + + public BookdropFinalizeResult finalizeImport(BookdropFinalizeRequest request) { + boolean monitoringWasActive = !monitoringService.isPaused(); + if (monitoringWasActive) monitoringService.pauseMonitoring(); + bookdropMonitoringService.pauseMonitoring(); + + BookdropFinalizeResult results = BookdropFinalizeResult.builder().build(); + + for (BookdropFinalizeRequest.BookdropFinalizeFile fileReq : request.getFiles()) { + try { + BookdropFileEntity fileEntity = bookdropFileRepository.findById(fileReq.getFileId()).orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException(fileReq.getFileId())); + + BookdropFileResult result = moveFile( + fileReq.getLibraryId(), + fileReq.getPathId(), + request.getUploadPattern(), + fileReq.getMetadata(), + fileEntity + ); + results.getResults().add(result); + } catch (Exception e) { + String msg = String.format("Failed to finalize file [id=%s]: %s", fileReq.getFileId(), e.getMessage()); + log.error(msg, e); + notificationService.sendMessage(Topic.LOG, msg); + } + } + + if (monitoringWasActive) { + Thread.startVirtualThread(() -> { + try { + Thread.sleep(5000); + monitoringService.resumeMonitoring(); + bookdropMonitoringService.resumeMonitoring(); + log.info("Monitoring resumed after 5s delay"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while delaying resume of monitoring"); + } + }); + } + + return results; + } + + private BookdropFileResult moveFile(long libraryId, long pathId, String filePattern, BookMetadata metadata, BookdropFileEntity bookdropFile) throws Exception { + LibraryEntity library = libraryRepository.findById(libraryId) + .orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); + + LibraryPathEntity path = library.getLibraryPaths().stream() + .filter(p -> p.getId() == pathId) + .findFirst() + .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); + + if (filePattern.endsWith("/") || filePattern.endsWith("\\")) { + filePattern += "{currentFilename}"; + } + + String relativePath = PathPatternResolver.resolvePattern(metadata, filePattern, bookdropFile.getFilePath()); + Path source = Path.of(bookdropFile.getFilePath()); + Path target = Paths.get(path.getPath(), relativePath); + File targetFile = target.toFile(); + + if (!Files.exists(source)) { + bookdropFileRepository.deleteById(bookdropFile.getId()); + log.warn("Source file [id={}] not found. Deleting entry.", bookdropFile.getId()); + bookdropNotificationService.sendBookdropFileSummaryNotification(); + return failureResult(targetFile.getName(), "Source file does not exist in bookdrop folder"); + } + + if (targetFile.exists()) { + return failureResult(targetFile.getName(), "File already exists in the library '" + library.getName() + "'"); + } + + Files.createDirectories(target.getParent()); + Files.move(source, target); + + Book processedBook = processFile(targetFile.getName(), library, path, targetFile, + BookFileExtension.fromFileName(bookdropFile.getFileName()) + .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")) + .getType() + ); + + BookEntity bookEntity = bookRepository.findById(processedBook.getId()) + .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); + + notificationService.sendMessage(Topic.BOOK_ADD, processedBook); + metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); + bookdropFileRepository.deleteById(bookdropFile.getId()); + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + File cachedCover = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFile.getId() + ".jpg").toFile(); + if (cachedCover.exists()) { + boolean deleted = cachedCover.delete(); + log.debug("Deleted cached cover image for bookdropId={}: {}", bookdropFile.getId(), deleted); + } + + return BookdropFileResult.builder() + .fileName(targetFile.getName()) + .message("File successfully imported into the '" + library.getName() + "' library from the Bookdrop folder") + .success(true) + .build(); + } + + private BookdropFileResult failureResult(String fileName, String message) { + return BookdropFileResult.builder() + .fileName(fileName) + .message(message) + .success(false) + .build(); + } + + private Book processFile(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { + LibraryFile libraryFile = LibraryFile.builder() + .libraryEntity(library) + .libraryPathEntity(path) + .fileSubPath(FileUtils.getRelativeSubPath(path.getPath(), file.toPath())) + .bookFileType(type) + .fileName(fileName) + .build(); + + return switch (type) { + case PDF -> pdfProcessor.processFile(libraryFile, false); + case EPUB -> epubProcessor.processFile(libraryFile, false); + case CBX -> cbxProcessor.processFile(libraryFile, false); + }; + } + + public void discardAllFiles() { + bookdropMonitoringService.pauseMonitoring(); + Path bookdropPath = Path.of(appProperties.getBookdropFolder()); + + AtomicInteger deletedFiles = new AtomicInteger(); + AtomicInteger deletedDirs = new AtomicInteger(); + AtomicInteger deletedCovers = new AtomicInteger(); + + try { + if (!Files.exists(bookdropPath)) { + log.info("Bookdrop folder does not exist: {}", bookdropPath); + return; + } + + try (Stream paths = Files.walk(bookdropPath)) { + paths.sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(bookdropPath)) + .forEach(path -> { + try { + if (Files.isRegularFile(path) && Files.deleteIfExists(path)) { + deletedFiles.incrementAndGet(); + } else if (Files.isDirectory(path) && Files.deleteIfExists(path)) { + deletedDirs.incrementAndGet(); + } + } catch (IOException e) { + log.warn("Failed to delete path: {}", path, e); + } + }); + } + + long removedDbCount = bookdropFileRepository.count(); + bookdropFileRepository.deleteAll(); + + Path tempCoverDir = Paths.get(appProperties.getPathConfig(), "bookdrop_temp"); + if (Files.exists(tempCoverDir)) { + try (Stream files = Files.walk(tempCoverDir)) { + files + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".jpg")) + .forEach(p -> { + try { + Files.delete(p); + deletedCovers.incrementAndGet(); + } catch (IOException e) { + log.warn("Failed to delete cached cover: {}", p, e); + } + }); + } catch (IOException e) { + log.warn("Failed to clean bookdrop_temp folder", e); + } + } + + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + log.info("Discarded all files: deleted {} files, {} folders, {} DB entries, and {} cover images", deletedFiles.get(), deletedDirs.get(), removedDbCount, deletedCovers.get()); + + } catch (IOException e) { + throw new RuntimeException("Failed to clean bookdrop folder", e); + } finally { + bookdropMonitoringService.resumeMonitoring(); + } + } + + public Resource getBookdropCover(long bookdropId) { + String coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropId + ".jpg").toString(); + File coverFile = new File(coverPath); + if (coverFile.exists() && coverFile.isFile()) { + return new PathResource(coverFile.toPath()); + } else { + return null; + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java new file mode 100644 index 00000000..7698b580 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java @@ -0,0 +1,138 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.model.BookDropFileEvent; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.enums.BookFileExtension; +import com.adityachandel.booklore.model.websocket.LogNotification; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.time.Instant; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BookdropEventHandlerService { + + private final BookdropFileRepository bookdropFileRepository; + private final NotificationService notificationService; + private final BookdropNotificationService bookdropNotificationService; + private final AppSettingService appSettingService; + private final BookdropMetadataService bookdropMetadataService; + + private final BlockingQueue fileQueue = new LinkedBlockingQueue<>(); + private volatile boolean running = true; + private Thread workerThread; + + @PostConstruct + public void init() { + workerThread = new Thread(this::processQueue, "BookdropFileProcessor"); + workerThread.start(); + } + + @PreDestroy + public void shutdown() { + running = false; + if (workerThread != null) { + workerThread.interrupt(); + } + } + + public void enqueueFile(Path file, WatchEvent.Kind kind) { + BookDropFileEvent event = new BookDropFileEvent(file, kind); + if (!fileQueue.contains(event)) { + fileQueue.offer(event); + } + } + + private void processQueue() { + while (running) { + try { + processFile(fileQueue.take()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.info("File processing thread interrupted, shutting down."); + } + } + } + + public void processFile(BookDropFileEvent event) { + Path file = event.getFile(); + WatchEvent.Kind kind = event.getKind(); + if (kind == StandardWatchEventKinds.ENTRY_CREATE) { + try { + if (!Files.exists(file)) { + log.warn("File does not exist, ignoring: {}", file); + return; + } + + if (Files.isDirectory(file)) { + log.info("New folder detected in bookdrop, ignoring: {}", file); + return; + } + + String filePath = file.toAbsolutePath().toString(); + String fileName = file.getFileName().toString(); + + if (BookFileExtension.fromFileName(fileName).isEmpty()) { + log.info("Unsupported file type detected, ignoring file: {}", fileName); + return; + } + + if (bookdropFileRepository.findByFilePath(filePath).isPresent()) { + log.info("File already processed in bookdrop, ignoring: {}", filePath); + return; + } + + log.info("Handling new bookdrop file: {}", file); + + int queueSize = fileQueue.size(); + notificationService.sendMessage(Topic.LOG, new LogNotification("Processing bookdrop file: " + fileName + " (" + queueSize + " books remaining)")); + + BookdropFileEntity bookdropFileEntity = BookdropFileEntity.builder() + .filePath(filePath) + .fileName(fileName) + .fileSize(Files.size(file)) + .status(BookdropFileEntity.Status.PENDING_REVIEW) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + + bookdropFileEntity = bookdropFileRepository.save(bookdropFileEntity); + + if (appSettingService.getAppSettings().isMetadataDownloadOnBookdrop()) { + bookdropMetadataService.attachInitialMetadata(bookdropFileEntity.getId()); + bookdropMetadataService.attachFetchedMetadata(bookdropFileEntity.getId()); + } else { + bookdropMetadataService.attachInitialMetadata(bookdropFileEntity.getId()); + log.info("Metadata download is disabled in settings. Only initial metadata extracted for file: {}", bookdropFileEntity.getFileName()); + } + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + notificationService.sendMessage(Topic.LOG, new LogNotification("Finished processing bookdrop file: " + fileName + " (" + queueSize + " books remaining)")); + } catch (Exception e) { + log.error("Error handling bookdrop file: {}", file, e); + } + } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) { + String deletedPath = event.getFile().toAbsolutePath().toString(); + log.info("Detected deletion event: {}", deletedPath); + + int deletedCount = bookdropFileRepository.deleteAllByFilePathStartingWith(deletedPath); + log.info("Deleted {} BookdropFile record(s) from database matching path: {}", deletedCount, deletedPath); + bookdropNotificationService.sendBookdropFileSummaryNotification(); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMetadataService.java new file mode 100644 index 00000000..4ab170f3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMetadataService.java @@ -0,0 +1,128 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest; +import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.enums.BookFileExtension; +import com.adityachandel.booklore.model.enums.MetadataProvider; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; +import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; +import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; +import com.adityachandel.booklore.util.FileService; +import com.adityachandel.booklore.util.ImageUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import static com.adityachandel.booklore.model.entity.BookdropFileEntity.Status.PENDING_REVIEW; + +@Slf4j +@AllArgsConstructor +@Service +public class BookdropMetadataService { + + private final BookdropFileRepository bookdropFileRepository; + private final AppSettingService appSettingService; + private final ObjectMapper objectMapper; + private final EpubMetadataExtractor epubMetadataExtractor; + private final PdfMetadataExtractor pdfMetadataExtractor; + private final CbxMetadataExtractor cbxMetadataExtractor; + private final MetadataRefreshService metadataRefreshService; + private final ImageUtils imageUtils; + private final FileService fileService; + + @Transactional + public BookdropFileEntity attachInitialMetadata(Long bookdropFileId) throws JsonProcessingException { + BookdropFileEntity entity = getOrThrow(bookdropFileId); + BookMetadata initial = extractInitialMetadata(entity); + extractAndSaveCover(entity); + String initialJson = objectMapper.writeValueAsString(initial); + entity.setOriginalMetadata(initialJson); + entity.setUpdatedAt(Instant.now()); + return bookdropFileRepository.save(entity); + } + + @Transactional + public BookdropFileEntity attachFetchedMetadata(Long bookdropFileId) throws JsonProcessingException { + BookdropFileEntity entity = getOrThrow(bookdropFileId); + + AppSettings appSettings = appSettingService.getAppSettings(); + MetadataRefreshRequest request = MetadataRefreshRequest.builder() + .refreshOptions(appSettings.getMetadataRefreshOptions()) + .build(); + + BookMetadata initial = objectMapper.readValue(entity.getOriginalMetadata(), BookMetadata.class); + + List providers = metadataRefreshService.prepareProviders(request); + Book book = Book.builder() + .fileName(entity.getFileName()) + .metadata(initial) + .build(); + + if (providers.contains(MetadataProvider.GoodReads)) { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(250, 1250)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + Map metadataMap = metadataRefreshService.fetchMetadataForBook(providers, book); + BookMetadata fetchedMetadata = metadataRefreshService.buildFetchMetadata(book.getId(), request, metadataMap); + String fetchedJson = objectMapper.writeValueAsString(fetchedMetadata); + + entity.setFetchedMetadata(fetchedJson); + entity.setStatus(PENDING_REVIEW); + entity.setUpdatedAt(Instant.now()); + + return bookdropFileRepository.save(entity); + } + + private BookdropFileEntity getOrThrow(Long id) { + return bookdropFileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("Bookdrop file not found: " + id)); + } + + private BookMetadata extractInitialMetadata(BookdropFileEntity entity) { + File file = new File(entity.getFilePath()); + BookFileExtension fileExt = BookFileExtension.fromFileName(file.getName()).orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); + return switch (fileExt) { + case PDF -> pdfMetadataExtractor.extractMetadata(file); + case EPUB -> epubMetadataExtractor.extractMetadata(file); + case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractMetadata(file); + }; + } + + private void extractAndSaveCover(BookdropFileEntity entity) { + File file = new File(entity.getFilePath()); + BookFileExtension fileExt = BookFileExtension.fromFileName(file.getName()).orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); + byte[] coverBytes; + coverBytes = switch (fileExt) { + case EPUB -> epubMetadataExtractor.extractCover(file); + case PDF -> pdfMetadataExtractor.extractCover(file); + case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractCover(file); + }; + if (coverBytes != null) { + try { + imageUtils.saveImage(coverBytes, fileService.getTempBookdropCoverImagePath(entity.getId()), 250, 350); + } catch (IOException e) { + log.warn("Failed to save extracted cover for file: {}", entity.getFilePath(), e); + } + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java new file mode 100644 index 00000000..6c07c4d1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java @@ -0,0 +1,194 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.model.enums.BookFileExtension; +import com.adityachandel.booklore.util.FileService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.*; +import java.util.stream.Stream; + +@Slf4j +@Service +public class BookdropMonitoringService { + + private final AppProperties appProperties; + private final BookdropEventHandlerService eventHandler; + + private Path bookdrop; + private WatchService watchService; + private Thread watchThread; + private volatile boolean running; + private WatchKey watchKey; + private volatile boolean paused; + + public BookdropMonitoringService(AppProperties appProperties, BookdropEventHandlerService eventHandler) { + this.appProperties = appProperties; + this.eventHandler = eventHandler; + } + + @PostConstruct + public void start() throws IOException { + bookdrop = Path.of(appProperties.getBookdropFolder()); + if (Files.notExists(bookdrop)) { + try { + Files.createDirectories(bookdrop); + log.info("Created missing bookdrop folder: {}", bookdrop); + } catch (IOException e) { + log.error("Failed to create bookdrop folder: {}", bookdrop, e); + throw e; + } + } + + log.info("Starting bookdrop folder monitor: {}", bookdrop); + this.watchService = FileSystems.getDefault().newWatchService(); + this.watchKey = bookdrop.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + this.running = true; + this.paused = false; + this.watchThread = new Thread(this::processEvents, "BookdropFolderWatcher"); + this.watchThread.setDaemon(true); + this.watchThread.start(); + scanExistingBookdropFiles(); + } + + @PreDestroy + public void stop() { + running = false; + if (watchThread != null) { + watchThread.interrupt(); + } + if (watchService != null) { + try { + watchService.close(); + } catch (IOException e) { + log.error("Error closing WatchService", e); + } + } + log.info("Stopped bookdrop folder monitor"); + } + + public synchronized void pauseMonitoring() { + if (!paused) { + if (watchKey != null) { + watchKey.cancel(); + watchKey = null; + } + paused = true; + log.info("Bookdrop monitoring paused."); + } else { + log.info("Bookdrop monitoring already paused."); + } + } + + public synchronized void resumeMonitoring() { + if (paused) { + try { + watchKey = bookdrop.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + paused = false; + log.info("Bookdrop monitoring resumed."); + } catch (IOException e) { + log.error("Error reregistering bookdrop folder during resume", e); + } + } else { + log.info("Bookdrop monitoring is not paused, cannot resume."); + } + } + + private void processEvents() { + while (running) { + if (paused) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + log.info("Bookdrop monitor thread interrupted during pause"); + Thread.currentThread().interrupt(); + return; + } + continue; + } + + WatchKey key; + try { + key = watchService.take(); + } catch (InterruptedException e) { + log.info("Bookdrop monitor thread interrupted"); + Thread.currentThread().interrupt(); + return; + } catch (ClosedWatchServiceException e) { + log.info("WatchService closed, stopping thread"); + return; + } + + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + + if (kind == StandardWatchEventKinds.OVERFLOW) { + log.warn("Overflow event detected"); + continue; + } + + Path context = (Path) event.context(); + Path fullPath = bookdrop.resolve(context); + + log.info("Detected {} event on: {}", kind.name(), fullPath); + + if (kind == StandardWatchEventKinds.ENTRY_CREATE || kind == StandardWatchEventKinds.ENTRY_MODIFY) { + if (Files.isDirectory(fullPath)) { + log.info("New directory detected, scanning recursively: {}", fullPath); + try (Stream pathStream = Files.walk(fullPath)) { + pathStream + .filter(Files::isRegularFile) + .filter(path -> BookFileExtension.fromFileName(path.getFileName().toString()).isPresent()) + .forEach(path -> eventHandler.enqueueFile(path, StandardWatchEventKinds.ENTRY_CREATE)); + } catch (IOException e) { + log.error("Failed to scan new directory: {}", fullPath, e); + } + } else { + if (BookFileExtension.fromFileName(fullPath.getFileName().toString()).isPresent()) { + eventHandler.enqueueFile(fullPath, kind); + } else { + log.info("Ignored unsupported file type: {}", fullPath); + } + } + } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) { + if (Files.isDirectory(fullPath)) { + log.info("Directory deleted: {}, performing bulk DB cleanup", fullPath); + } else { + log.info("File deleted: {}", fullPath); + } + eventHandler.enqueueFile(fullPath, kind); + } + } + + boolean valid = key.reset(); + if (!valid) { + log.warn("WatchKey is no longer valid"); + break; + } + } + } + + private void scanExistingBookdropFiles() { + try (Stream files = Files.walk(bookdrop)) { + files.filter(Files::isRegularFile) + .filter(path -> BookFileExtension.fromFileName(path.getFileName().toString()).isPresent()) + .forEach(file -> { + log.info("Found existing supported file on startup: {}", file); + eventHandler.enqueueFile(file, StandardWatchEventKinds.ENTRY_CREATE); + }); + } catch (IOException e) { + log.error("Error scanning bookdrop folder on startup", e); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java new file mode 100644 index 00000000..ae893db2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java @@ -0,0 +1,31 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.model.dto.BookdropFileNotification; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.NotificationService; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import java.time.Instant; + +@Service +@AllArgsConstructor +public class BookdropNotificationService { + + private final BookdropFileRepository bookdropFileRepository; + private final NotificationService notificationService; + + public void sendBookdropFileSummaryNotification() { + long pendingCount = bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); + long totalCount = bookdropFileRepository.count(); + + BookdropFileNotification summaryNotification = new BookdropFileNotification( + (int) pendingCount, + (int) totalCount, + Instant.now().toString() + ); + + notificationService.sendMessage(Topic.BOOKDROP_FILE, summaryNotification); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index b79bd192..22bb6340 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -1,6 +1,5 @@ package com.adityachandel.booklore.service.metadata; -import com.adityachandel.booklore.config.security.AuthenticationService; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.mapper.BookMetadataMapper; @@ -17,7 +16,8 @@ import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.enums.Lock; import com.adityachandel.booklore.model.enums.MetadataProvider; import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.repository.BookMetadataRepository; +import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.NotificationService; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -29,7 +29,6 @@ import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupR import com.adityachandel.booklore.service.metadata.parser.BookParser; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; import com.adityachandel.booklore.util.FileService; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index 148855de..a85f8459 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -10,20 +10,21 @@ import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest; import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions; import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest; import com.adityachandel.booklore.model.dto.settings.AppSettings; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.MetadataFetchProposalEntity; -import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.entity.MetadataFetchJobEntity; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.model.enums.FetchedMetadataProposalStatus; import com.adityachandel.booklore.model.enums.MetadataFetchTaskStatus; import com.adityachandel.booklore.model.enums.MetadataProvider; import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.MetadataFetchProposalRepository; +import com.adityachandel.booklore.repository.BookdropFileRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.repository.MetadataFetchJobRepository; +import com.adityachandel.booklore.repository.MetadataFetchProposalRepository; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.NotificationService; import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; +import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; import com.adityachandel.booklore.service.metadata.parser.BookParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,12 +33,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.File; import java.time.Instant; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; +import static com.adityachandel.booklore.model.entity.BookdropFileEntity.Status.PENDING_REVIEW; import static com.adityachandel.booklore.model.enums.MetadataProvider.*; import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification; @@ -186,7 +189,7 @@ public class MetadataRefreshService { } @Transactional - protected void updateBookMetadata(BookEntity bookEntity, BookMetadata metadata, boolean replaceCover, boolean mergeCategories) { + public void updateBookMetadata(BookEntity bookEntity, BookMetadata metadata, boolean replaceCover, boolean mergeCategories) { if (metadata != null) { MetadataUpdateWrapper metadataUpdateWrapper = MetadataUpdateWrapper.builder() .metadata(metadata) @@ -200,7 +203,7 @@ public class MetadataRefreshService { } @Transactional - protected List prepareProviders(MetadataRefreshRequest request) { + public List prepareProviders(MetadataRefreshRequest request) { Set allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(request)); return new ArrayList<>(allProviders); } @@ -231,6 +234,23 @@ public class MetadataRefreshService { } } + @Transactional + public Map fetchMetadataForBook(List providers, Book book) { + return providers.stream() + .map(provider -> CompletableFuture.supplyAsync(() -> fetchTopMetadataFromAProvider(provider, book)) + .exceptionally(e -> { + log.error("Error fetching metadata from provider: {}", provider, e); + return null; + })) + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .collect(Collectors.toMap( + BookMetadata::getProvider, + metadata -> metadata, + (existing, replacement) -> existing + )); + } + @Transactional protected Map fetchMetadataForBook(List providers, BookEntity bookEntity) { return providers.stream() @@ -261,17 +281,18 @@ public class MetadataRefreshService { } private FetchMetadataRequest buildFetchMetadataRequestFromBook(Book book) { + BookMetadata metadata = book.getMetadata(); return FetchMetadataRequest.builder() - .isbn(book.getMetadata().getIsbn10()) - .asin(book.getMetadata().getAsin()) - .author(String.join(", ", book.getMetadata().getAuthors())) - .title(book.getMetadata().getTitle()) + .isbn(metadata.getIsbn10()) + .asin(metadata.getAsin()) + .author(metadata.getAuthors() != null ? String.join(", ", metadata.getAuthors()) : null) + .title(metadata.getTitle()) .bookId(book.getId()) .build(); } @Transactional - protected BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshRequest request, Map metadataMap) { + public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshRequest request, Map metadataMap) { BookMetadata metadata = BookMetadata.builder().bookId(bookId).build(); MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java new file mode 100644 index 00000000..917c36c8 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java @@ -0,0 +1,58 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +@Slf4j +@Component +public class CbxMetadataExtractor implements FileMetadataExtractor { + + @Override + public BookMetadata extractMetadata(File file) { + String baseName = FilenameUtils.getBaseName(file.getName()); + return BookMetadata.builder() + .title(baseName) + .build(); + } + + @Override + public byte[] extractCover(File file) { + return generatePlaceholderCover(250, 350); + } + + private byte[] generatePlaceholderCover(int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, width, height); + + g.setColor(Color.DARK_GRAY); + g.setFont(new Font("SansSerif", Font.BOLD, width / 10)); + FontMetrics fm = g.getFontMetrics(); + String text = "Preview Unavailable"; + + int textWidth = fm.stringWidth(text); + int textHeight = fm.getAscent(); + g.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2); + + g.dispose(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", baos); + return baos.toByteArray(); + } catch (IOException e) { + log.warn("Failed to generate placeholder image", e); + return null; + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/EpubMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/EpubMetadataExtractor.java index dce70fe0..10fbf119 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/EpubMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/EpubMetadataExtractor.java @@ -1,6 +1,8 @@ package com.adityachandel.booklore.service.metadata.extractor; import com.adityachandel.booklore.model.dto.BookMetadata; +import io.documentnode.epub4j.domain.Book; +import io.documentnode.epub4j.epub.EpubReader; import lombok.extern.slf4j.Slf4j; import net.lingala.zip4j.ZipFile; import net.lingala.zip4j.model.FileHeader; @@ -13,8 +15,7 @@ import org.w3c.dom.NodeList; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; -import java.io.File; -import java.io.InputStream; +import java.io.*; import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.HashSet; @@ -24,6 +25,33 @@ import java.util.Set; @Component public class EpubMetadataExtractor implements FileMetadataExtractor { + @Override + public byte[] extractCover(File epubFile) { + try { + Book epub = new EpubReader().readEpub(new FileInputStream(epubFile)); + io.documentnode.epub4j.domain.Resource coverImage = epub.getCoverImage(); + + if (coverImage == null) { + for (io.documentnode.epub4j.domain.Resource res : epub.getResources().getAll()) { + String id = res.getId(); + String href = res.getHref(); + if ((id != null && id.toLowerCase().contains("cover")) || + (href != null && href.toLowerCase().contains("cover"))) { + if (res.getMediaType() != null && res.getMediaType().getName().startsWith("image")) { + coverImage = res; + break; + } + } + } + } + + return (coverImage != null) ? coverImage.getData() : null; + } catch (Exception e) { + log.warn("Failed to extract cover from EPUB: {}", epubFile.getName(), e); + return null; + } + } + public BookMetadata extractMetadata(File epubFile) { try (ZipFile zip = new ZipFile(epubFile)) { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); @@ -157,6 +185,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor { } } + private void safeParseInt(String value, java.util.function.IntConsumer setter) { try { setter.accept(Integer.parseInt(value)); @@ -185,7 +214,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor { } try { - return LocalDate.parse(value.substring(0, 10)); // fallback to prefix + return LocalDate.parse(value.substring(0, 10)); } catch (Exception ignored) { } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/FileMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/FileMetadataExtractor.java index 741d7273..e86c5920 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/FileMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/FileMetadataExtractor.java @@ -7,4 +7,6 @@ import java.io.File; public interface FileMetadataExtractor { BookMetadata extractMetadata(File file); + + byte[] extractCover(File file); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java index 4b87c401..ef8e0cd9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore.service.metadata.extractor; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.util.FileUtils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; @@ -10,19 +11,25 @@ import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; import org.springframework.messaging.rsocket.MetadataExtractor; import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.NodeList; +import javax.imageio.ImageIO; import javax.xml.namespace.NamespaceContext; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.xpath.*; +import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.*; @@ -32,6 +39,21 @@ import java.util.stream.Collectors; @Slf4j public class PdfMetadataExtractor implements FileMetadataExtractor { + + @Override + public byte[] extractCover(File file) { + try (PDDocument pdf = Loader.loadPDF(file)) { + BufferedImage coverImage = new PDFRenderer(pdf).renderImageWithDPI(0, 300, ImageType.RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(coverImage, "jpg", baos); + return baos.toByteArray(); + } catch (Exception e) { + log.warn("Failed to extract cover from PDF: {}", file.getAbsolutePath(), e); + return null; + } + } + + @Override public BookMetadata extractMetadata(File file) { if (!file.exists() || !file.isFile()) { log.warn("File does not exist or is not a file: {}", file.getPath()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java index 96cf6cf0..8c9351ba 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java @@ -37,11 +37,7 @@ public class MonitoringService { private int pauseCount = 0; private final Object pauseLock = new Object(); - public MonitoringService( - LibraryFileEventProcessor libraryFileEventProcessor, - WatchService watchService, - MonitoringTask monitoringTask - ) { + public MonitoringService(LibraryFileEventProcessor libraryFileEventProcessor, WatchService watchService, MonitoringTask monitoringTask) { this.libraryFileEventProcessor = libraryFileEventProcessor; this.watchService = watchService; this.monitoringTask = monitoringTask; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index 05ed823d..66fe5483 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -84,25 +84,6 @@ public class FileService { } } - public Resource getBackupBookCover(String thumbnailPath) { - Path thumbPath; - if (thumbnailPath == null || thumbnailPath.isEmpty()) { - thumbPath = Paths.get(getMissingThumbnailPath()); - } else { - thumbPath = Paths.get(thumbnailPath); - } - try { - Resource resource = new UrlResource(thumbPath.toUri()); - if (resource.exists() && resource.isReadable()) { - return resource; - } else { - throw ApiError.IMAGE_NOT_FOUND.createException(thumbPath); - } - } catch (IOException e) { - throw ApiError.IMAGE_NOT_FOUND.createException(thumbPath); - } - } - public String createThumbnail(long bookId, String thumbnailUrl) throws IOException { String newFilename = "f.jpg"; resizeAndSaveImage(thumbnailUrl, new File(getThumbnailPath(bookId)), newFilename); @@ -170,4 +151,12 @@ public class FileService { public String getMissingThumbnailPath() { return appProperties.getPathConfig() + "/thumbs/missing/m.jpg"; } + + public String getTempBookdropCoverImagePath(long bookdropFileId) { + return Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFileId + ".jpg").toString(); + } + + public String getBookdropPath() { + return appProperties.getBookdropFolder(); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/ImageUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/ImageUtils.java new file mode 100644 index 00000000..8e55acd4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/ImageUtils.java @@ -0,0 +1,37 @@ +package com.adityachandel.booklore.util; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; + +@Service +public class ImageUtils { + + public void saveImage(byte[] imageData, String filePath, Integer width, Integer height) throws IOException { + BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData)); + BufferedImage resizedImage = resizeImage(originalImage, width, height); + + File outputFile = new File(filePath); + File parentDir = outputFile.getParentFile(); + if (!parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create directory: " + parentDir); + } + + ImageIO.write(resizedImage, "JPEG", outputFile); + } + + private BufferedImage resizeImage(BufferedImage originalImage, int width, int height) { + Image tmp = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH); + BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = resizedImage.createGraphics(); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.drawImage(tmp, 0, 0, null); + g2d.dispose(); + return resizedImage; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/resources/application.yaml b/booklore-api/src/main/resources/application.yaml index fde3cbb4..4ab77b72 100644 --- a/booklore-api/src/main/resources/application.yaml +++ b/booklore-api/src/main/resources/application.yaml @@ -1,5 +1,6 @@ app: path-config: '/app/data' + bookdrop-folder: '/bookdrop' version: 'v0.0.40' swagger: enabled: ${SWAGGER_ENABLED:false} diff --git a/booklore-api/src/main/resources/db/migration/V38__Create_bookdrop_file_table.sql b/booklore-api/src/main/resources/db/migration/V38__Create_bookdrop_file_table.sql new file mode 100644 index 00000000..cc5df75c --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V38__Create_bookdrop_file_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE bookdrop_file +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + file_path TEXT NOT NULL, + file_name VARCHAR(512) NOT NULL, + file_size BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW', + original_metadata JSON, + fetched_metadata JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_file_path (file_path(255)) +); \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookDropServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookDropServiceTest.java new file mode 100644 index 00000000..a9c48028 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookDropServiceTest.java @@ -0,0 +1,127 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.repository.LibraryRepository; +import com.adityachandel.booklore.service.bookdrop.BookDropService; +import com.adityachandel.booklore.service.bookdrop.BookdropNotificationService; +import com.adityachandel.booklore.service.fileprocessor.CbxProcessor; +import com.adityachandel.booklore.service.fileprocessor.EpubProcessor; +import com.adityachandel.booklore.service.fileprocessor.PdfProcessor; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.adityachandel.booklore.service.bookdrop.BookdropMonitoringService; +import com.adityachandel.booklore.service.monitoring.MonitoringService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class BookDropServiceTest { + + private BookdropFileRepository bookdropFileRepository; + private LibraryRepository libraryRepository; + private BookRepository bookRepository; + private MonitoringService monitoringService; + private BookdropMonitoringService bookdropMonitoringService; + private NotificationService notificationService; + private MetadataRefreshService metadataRefreshService; + private BookdropNotificationService bookdropNotificationService; + private PdfProcessor pdfProcessor; + private EpubProcessor epubProcessor; + private CbxProcessor cbxProcessor; + private AppProperties appProperties; + + private BookDropService bookDropService; + + @BeforeEach + void setUp() { + bookdropFileRepository = mock(BookdropFileRepository.class); + libraryRepository = mock(LibraryRepository.class); + bookRepository = mock(BookRepository.class); + monitoringService = mock(MonitoringService.class); + bookdropMonitoringService = mock(BookdropMonitoringService.class); + notificationService = mock(NotificationService.class); + metadataRefreshService = mock(MetadataRefreshService.class); + bookdropNotificationService = mock(BookdropNotificationService.class); + pdfProcessor = mock(PdfProcessor.class); + epubProcessor = mock(EpubProcessor.class); + cbxProcessor = mock(CbxProcessor.class); + appProperties = mock(AppProperties.class); + + bookDropService = new BookDropService( + bookdropFileRepository, libraryRepository, bookRepository, + monitoringService, bookdropMonitoringService, + notificationService, metadataRefreshService, + bookdropNotificationService, + pdfProcessor, epubProcessor, cbxProcessor, + appProperties + ); + } + + @Test + void discardAllFiles_shouldDeleteFilesDirsAndNotify() throws Exception { + Path bookdropPath = Paths.get("/tmp/bookdrop"); + when(appProperties.getBookdropFolder()).thenReturn(bookdropPath.toString()); + + Files.createDirectories(bookdropPath); + Path testFile = bookdropPath.resolve("testfile.txt"); + Files.createFile(testFile); + + when(bookdropFileRepository.count()).thenReturn(1L); + + Path tempCoverDir = Paths.get("/tmp/config/bookdrop_temp"); + when(appProperties.getPathConfig()).thenReturn("/tmp/config"); + Files.createDirectories(tempCoverDir); + Path tempCover = tempCoverDir.resolve("1.jpg"); + Files.createFile(tempCover); + + bookDropService.discardAllFiles(); + + verify(bookdropFileRepository).deleteAll(); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + + Files.deleteIfExists(testFile); + Files.deleteIfExists(bookdropPath); + Files.deleteIfExists(tempCover); + Files.deleteIfExists(tempCoverDir); + } + + @Test + void getBookdropCover_shouldReturnResourceIfExists() throws Exception { + when(appProperties.getPathConfig()).thenReturn("/tmp/config"); + long bookdropId = 123L; + Path coverPath = Paths.get("/tmp/config/bookdrop_temp", bookdropId + ".jpg"); + File coverFile = coverPath.toFile(); + + coverFile.getParentFile().mkdirs(); + coverFile.createNewFile(); + + Resource resource = bookDropService.getBookdropCover(bookdropId); + + assertThat(resource).isInstanceOf(PathResource.class); + assertThat(((PathResource) resource).getFile().getName()).isEqualTo(bookdropId + ".jpg"); + + coverFile.delete(); + } + + @Test + void getBookdropCover_shouldReturnNullIfNotExists() { + when(appProperties.getPathConfig()).thenReturn("/tmp/config"); + long bookdropId = 99999L; + + Resource resource = bookDropService.getBookdropCover(bookdropId); + + assertThat(resource).isNull(); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropMetadataServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropMetadataServiceTest.java new file mode 100644 index 00000000..63c39e97 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropMetadataServiceTest.java @@ -0,0 +1,183 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.exception.APIException; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.enums.MetadataProvider; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.bookdrop.BookdropMetadataService; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; +import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; +import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; +import com.adityachandel.booklore.util.FileService; +import com.adityachandel.booklore.util.ImageUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.adityachandel.booklore.model.entity.BookdropFileEntity.Status.PENDING_REVIEW; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BookdropMetadataServiceTest { + + @Mock + private BookdropFileRepository bookdropFileRepository; + @Mock + private AppSettingService appSettingService; + @Mock + private ObjectMapper objectMapper; + @Mock + private EpubMetadataExtractor epubMetadataExtractor; + @Mock + private PdfMetadataExtractor pdfMetadataExtractor; + @Mock + private CbxMetadataExtractor cbxMetadataExtractor; + @Mock + private MetadataRefreshService metadataRefreshService; + @Mock + private ImageUtils imageUtils; + @Mock + private FileService fileService; + + @InjectMocks + private BookdropMetadataService bookdropMetadataService; + + private BookdropFileEntity sampleFile; + + @BeforeEach + void setup() { + sampleFile = new BookdropFileEntity(); + sampleFile.setId(1L); + sampleFile.setFileName("book.epub"); + sampleFile.setFilePath("/tmp/book.epub"); + } + + @Test + void attachInitialMetadata_shouldExtractAndSaveMetadata() throws Exception { + BookMetadata metadata = BookMetadata.builder().title("Test Book").build(); + + when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile)); + when(epubMetadataExtractor.extractMetadata(any(File.class))).thenReturn(metadata); + when(objectMapper.writeValueAsString(metadata)).thenReturn("{\"title\":\"Test Book\"}"); + when(bookdropFileRepository.save(any())).thenReturn(sampleFile); + + BookdropFileEntity result = bookdropMetadataService.attachInitialMetadata(1L); + + assertThat(result).isNotNull(); + assertThat(result.getOriginalMetadata()).contains("Test Book"); + assertThat(result.getUpdatedAt()).isBeforeOrEqualTo(Instant.now()); + verify(bookdropFileRepository).save(result); + } + + @Test + void attachInitialMetadata_shouldThrowWhenFileMissing() { + when(bookdropFileRepository.findById(99L)).thenReturn(Optional.empty()); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> bookdropMetadataService.attachInitialMetadata(99L)); + } + + @Test + void attachFetchedMetadata_shouldUpdateEntityWithFetchedData() throws Exception { + sampleFile.setOriginalMetadata("{\"title\":\"Old Book\"}"); + AppSettings settings = new AppSettings(); + BookMetadata fetched = BookMetadata.builder().title("New Title").build(); + + when(bookdropFileRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile)); + when(appSettingService.getAppSettings()).thenReturn(settings); + when(metadataRefreshService.prepareProviders(any())).thenReturn(List.of()); + when(objectMapper.readValue(sampleFile.getOriginalMetadata(), BookMetadata.class)).thenReturn(fetched); + when(metadataRefreshService.fetchMetadataForBook(any(), any())).thenReturn(Map.of()); + when(metadataRefreshService.buildFetchMetadata(any(), any(), any())).thenReturn(fetched); + when(objectMapper.writeValueAsString(fetched)).thenReturn("{\"title\":\"New Title\"}"); + + BookdropFileEntity result = bookdropMetadataService.attachFetchedMetadata(1L); + + assertThat(result.getFetchedMetadata()).contains("New Title"); + assertThat(result.getStatus()).isEqualTo(PENDING_REVIEW); + verify(bookdropFileRepository).save(result); + } + + @Test + void attachInitialMetadata_shouldHandleNullCoverGracefully() throws Exception { + BookMetadata metadata = BookMetadata.builder().title("No Cover Book").build(); + + when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile)); + when(epubMetadataExtractor.extractMetadata(any(File.class))).thenReturn(metadata); + when(objectMapper.writeValueAsString(metadata)).thenReturn("{\"title\":\"No Cover Book\"}"); + when(epubMetadataExtractor.extractCover(any(File.class))).thenReturn(null); + when(bookdropFileRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + BookdropFileEntity result = bookdropMetadataService.attachInitialMetadata(1L); + + assertThat(result.getOriginalMetadata()).contains("No Cover Book"); + verify(imageUtils, never()).saveImage(any(), any(), anyInt(), anyInt()); + verify(bookdropFileRepository).save(result); + } + + @Test + void extractInitialMetadata_shouldThrowForUnsupportedFileExtension() { + sampleFile.setFileName("book.txt"); + sampleFile.setFilePath("/tmp/book.txt"); + + when(bookdropFileRepository.findById(sampleFile.getId())).thenReturn(Optional.of(sampleFile)); + + assertThatThrownBy(() -> { + bookdropMetadataService.attachInitialMetadata(sampleFile.getId()); + }).isInstanceOf(APIException.class) + .hasMessageContaining("Invalid file format"); + } + + @Test + void attachFetchedMetadata_shouldSleepIfGoodreadsIncluded() throws Exception { + sampleFile.setOriginalMetadata("{\"title\":\"Book\"}"); + AppSettings settings = new AppSettings(); + BookMetadata fetched = BookMetadata.builder().title("Fetched Book").build(); + + when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile)); + when(appSettingService.getAppSettings()).thenReturn(settings); + when(metadataRefreshService.prepareProviders(any())).thenReturn(List.of(MetadataProvider.GoodReads)); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(fetched); + when(metadataRefreshService.fetchMetadataForBook(any(), any())).thenReturn(Map.of()); + when(metadataRefreshService.buildFetchMetadata(any(), any(), any())).thenReturn(fetched); + when(objectMapper.writeValueAsString(fetched)).thenReturn("{\"title\":\"Fetched Book\"}"); + when(bookdropFileRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + BookdropFileEntity result = bookdropMetadataService.attachFetchedMetadata(1L); + + assertThat(result.getFetchedMetadata()).contains("Fetched Book"); + assertThat(result.getStatus()).isEqualTo(PENDING_REVIEW); + verify(bookdropFileRepository).save(result); + } + + @Test + void attachFetchedMetadata_shouldThrowOnJsonProcessingError() throws Exception { + sampleFile.setOriginalMetadata("{invalidJson}"); + + when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile)); + when(appSettingService.getAppSettings()).thenReturn(new AppSettings()); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))) + .thenThrow(new JsonProcessingException("Invalid JSON") { + }); + + assertThatThrownBy(() -> bookdropMetadataService.attachFetchedMetadata(1L)) + .isInstanceOf(JsonProcessingException.class); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropNotificationServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropNotificationServiceTest.java new file mode 100644 index 00000000..6cbed498 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookdropNotificationServiceTest.java @@ -0,0 +1,61 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.dto.BookdropFileNotification; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.service.bookdrop.BookdropNotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class BookdropNotificationServiceTest { + + private BookdropFileRepository bookdropFileRepository; + private NotificationService notificationService; + + private BookdropNotificationService bookdropNotificationService; + + @BeforeEach + void setup() { + bookdropFileRepository = mock(BookdropFileRepository.class); + notificationService = mock(NotificationService.class); + + bookdropNotificationService = new BookdropNotificationService(bookdropFileRepository, notificationService); + } + + @Test + void sendBookdropFileSummaryNotification_shouldSendCorrectNotification() { + long pendingCount = 5L; + long totalCount = 20L; + + when(bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW)).thenReturn(pendingCount); + when(bookdropFileRepository.count()).thenReturn(totalCount); + + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BookdropFileNotification.class); + verify(notificationService).sendMessage(eq(Topic.BOOKDROP_FILE), captor.capture()); + + BookdropFileNotification sentNotification = captor.getValue(); + + assertThat(sentNotification.getPendingCount()).isEqualTo((int) pendingCount); + assertThat(sentNotification.getTotalCount()).isEqualTo((int) totalCount); + assertThat(Instant.parse(sentNotification.getLastUpdatedAt())).isBeforeOrEqualTo(Instant.now()); + } + + @Test + void sendBookdropFileSummaryNotification_shouldSendEvenIfCountsAreZero() { + when(bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW)).thenReturn(0L); + when(bookdropFileRepository.count()).thenReturn(0L); + + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + verify(notificationService).sendMessage(eq(Topic.BOOKDROP_FILE), any(BookdropFileNotification.class)); + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/app.component.ts b/booklore-ui/src/app/app.component.ts index 6219129a..2d0c9d76 100644 --- a/booklore-ui/src/app/app.component.ts +++ b/booklore-ui/src/app/app.component.ts @@ -11,6 +11,7 @@ import {AuthInitializationService} from './auth-initialization-service'; import {AppConfigService} from './core/service/app-config.service'; import {MetadataBatchProgressNotification} from './core/model/metadata-batch-progress.model'; import {MetadataProgressService} from './core/service/metadata-progress-service'; +import {BookdropFileService, BookdropFileNotification} from './bookdrop/bookdrop-file.service'; @Component({ selector: 'app-root', @@ -27,6 +28,7 @@ export class AppComponent implements OnInit { private rxStompService = inject(RxStompService); private notificationEventService = inject(NotificationEventService); private metadataProgressService = inject(MetadataProgressService); + private bookdropFileService = inject(BookdropFileService); private appConfigService = inject(AppConfigService); ngOnInit(): void { @@ -61,5 +63,10 @@ export class AppComponent implements OnInit { const logNotification = parseLogNotification(message.body); this.notificationEventService.handleNewNotification(logNotification); }); + + this.rxStompService.watch('/topic/bookdrop-file').subscribe((message: Message) => { + const notification = JSON.parse(message.body) as BookdropFileNotification; + this.bookdropFileService.handleIncomingFile(notification); + }); } } diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 82689bcc..0897b4f8 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -16,6 +16,7 @@ import {EmptyComponent} from './core/empty/empty.component'; import {LoginGuard} from './core/setup/ login.guard'; import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback.component'; import {CbxReaderComponent} from './book/components/cbx-reader/cbx-reader.component'; +import {BookdropFileReviewComponent} from './bookdrop/bookdrop-file-review-component/bookdrop-file-review.component'; export const routes: Routes = [ { @@ -40,7 +41,8 @@ export const routes: Routes = [ {path: 'library/:libraryId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'shelf/:shelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'unshelved-books', component: BookBrowserComponent, canActivate: [AuthGuard]}, - {path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]} + {path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]}, + {path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [AuthGuard]} ] }, { diff --git a/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts b/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts index a2ca0622..192a4a80 100644 --- a/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts +++ b/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts @@ -6,7 +6,7 @@ import {MetadataFetchOptionsComponent} from '../../../metadata/metadata-options- import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refresh-type.enum'; import {BulkMetadataUpdateComponent} from '../../../metadata/bulk-metadata-update-component/bulk-metadata-update-component'; import {MultiBookMetadataEditorComponent} from '../../../metadata/multi-book-metadata-editor-component/multi-book-metadata-editor-component'; -import {FileMoverComponent} from '../../../file-mover-component/file-mover-component'; +import {FileMoverComponent} from '../../../utilities/component/file-mover-component/file-mover-component'; import {count} from 'rxjs'; import {MultiBookMetadataFetchComponent} from '../../../metadata/multi-book-metadata-fetch-component/multi-book-metadata-fetch-component'; diff --git a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts index ae7d311c..2929c614 100644 --- a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts +++ b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts @@ -11,7 +11,7 @@ import {ConfirmationService, MessageService} from 'primeng/api'; import {Router} from '@angular/router'; import {filter} from 'rxjs'; import {NgClass} from '@angular/common'; -import {BookMetadataHostService} from '../../../book-metadata-host-service'; +import {BookMetadataHostService} from '../../../utilities/service/book-metadata-host-service'; @Component({ selector: 'app-book-card-lite-component', diff --git a/booklore-ui/src/app/book/service/metadata-task.ts b/booklore-ui/src/app/book/service/metadata-task.ts index a2f1edaa..cc08952f 100644 --- a/booklore-ui/src/app/book/service/metadata-task.ts +++ b/booklore-ui/src/app/book/service/metadata-task.ts @@ -11,7 +11,7 @@ export enum FetchedMetadataProposalStatus { REJECTED = 'REJECTED', } -export interface FetchedProposalDto { +export interface FetchedProposal { proposalId: number; taskId: string; bookId: number; @@ -32,7 +32,7 @@ export interface MetadataFetchTask { initiatedBy: string; errorMessage: string | null; - proposals: FetchedProposalDto[]; + proposals: FetchedProposal[]; } @Injectable({ diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts b/booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts new file mode 100644 index 00000000..9d7673c0 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts @@ -0,0 +1,18 @@ +import {inject, Injectable} from '@angular/core'; +import {API_CONFIG} from '../config/api-config'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {BookdropFileNotification} from './bookdrop-file.service'; + +@Injectable({ + providedIn: 'root' +}) +export class BookdropFileApiService { + + private readonly url = `${API_CONFIG.BASE_URL}/api/bookdrop`; + private http = inject(HttpClient); + + getNotification(): Observable { + return this.http.get(`${this.url}/notification`); + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.html b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.html new file mode 100644 index 00000000..9ed02a86 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.html @@ -0,0 +1,245 @@ +@if (fetchedMetadata) { +
+
+
+
+

Current Metadata

+
+ + +
+

Fetched Metadata

+
+
+ +
+
+
+
+ +
+ Book Thumbnail + + + + Fetched Thumbnail +
+
+ @for (field of metadataFieldsTop; track field) { +
+ +
+ + + +
+
+ } + @for (field of metadataChips; track field) { +
+ +
+
+ +
+ + +
+ + +
+
+
+ } + @for (field of metadataDescription; track field) { +
+ +
+ + + +
+
+ } + @for (field of metadataFieldsBottom; track field) { +
+ +
+ + + +
+
+ } +
+
+
+} @else { +
+
+

Current Metadata:

+ + @for (field of metadataFieldsTop; track field) { +
+ +
+ +
+
+ } + + @for (field of metadataChips; track field) { +
+ +
+ +
+
+ } + + @for (field of metadataDescription; track field) { +
+ +
+ +
+
+ } + + @for (field of metadataFieldsBottom; track field) { +
+ +
+ +
+
+ } +
+ +
+
+ Book Thumbnail + +
+
+
+} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.scss b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.scss new file mode 100644 index 00000000..6ea5335c --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.scss @@ -0,0 +1,31 @@ +.thumbnail { + width: 10.46875rem; + height: 14.65625rem; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + margin-left: 2.5rem; + margin-right: 2.5rem; +} + +.arrow-button { + padding-left: 1rem; + padding-right: 1rem; +} + +.green-outlined-button { + --p-button-outlined-primary-border-color: forestgreen; + --p-button-outlined-primary-color: forestgreen; +} + +.red-outlined-button { + --p-button-outlined-primary-border-color: #E32636; + --p-button-outlined-primary-color: #E32636; +} + +.outlined-input-green { + border: 0.75px solid forestgreen !important; +} + +::ng-deep .p-inputchips { + width: 100% !important; +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.ts b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.ts new file mode 100644 index 00000000..869bc9d3 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component.ts @@ -0,0 +1,168 @@ +import {Component, EventEmitter, inject, Input, Output} from '@angular/core'; +import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {Button} from 'primeng/button'; +import {NgClass} from '@angular/common'; +import {Tooltip} from 'primeng/tooltip'; +import {InputText} from 'primeng/inputtext'; +import {BookMetadata} from '../../book/model/book.model'; +import {UrlHelperService} from '../../utilities/service/url-helper.service'; +import {Chips} from 'primeng/chips'; +import {Textarea} from 'primeng/textarea'; + +@Component({ + selector: 'app-bookdrop-file-metadata-picker-component', + imports: [ + ReactiveFormsModule, + Button, + Tooltip, + InputText, + NgClass, + Chips, + FormsModule, + Textarea + ], + templateUrl: './bookdrop-file-metadata-picker.component.html', + styleUrl: './bookdrop-file-metadata-picker.component.scss' +}) +export class BookdropFileMetadataPickerComponent { + + @Input() fetchedMetadata!: BookMetadata; + @Input() originalMetadata!: BookMetadata; + @Input() metadataForm!: FormGroup; + @Input() copiedFields: Record = {}; + @Input() savedFields: Record = {}; + @Input() bookdropFileId!: number; + + @Output() metadataCopied = new EventEmitter(); + + + metadataFieldsTop = [ + {label: 'Title', controlName: 'title', fetchedKey: 'title'}, + {label: 'Publisher', controlName: 'publisher', fetchedKey: 'publisher'}, + {label: 'Published', controlName: 'publishedDate', fetchedKey: 'publishedDate'} + ]; + + metadataChips = [ + {label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors'}, + {label: 'Categories', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'} + ]; + + metadataDescription = [ + {label: 'Description', controlName: 'description', lockedKey: 'descriptionLocked', fetchedKey: 'description'}, + ]; + + metadataFieldsBottom = [ + {label: 'Series', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName'}, + {label: 'Book #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber'}, + {label: 'Total Books', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal'}, + {label: 'Language', controlName: 'language', lockedKey: 'languageLocked', fetchedKey: 'language'}, + {label: 'ISBN-10', controlName: 'isbn10', lockedKey: 'isbn10Locked', fetchedKey: 'isbn10'}, + {label: 'ISBN-13', controlName: 'isbn13', lockedKey: 'isbn13Locked', fetchedKey: 'isbn13'}, + {label: 'ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin'}, + {label: 'Amz Reviews', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount'}, + {label: 'Amz Rating', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating'}, + {label: 'GR ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId'}, + {label: 'GR Reviews', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'}, + {label: 'GR Rating', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'}, + {label: 'HC ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'}, + {label: 'HC Reviews', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'}, + {label: 'HC Rating', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'}, + {label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleIdRating'}, + {label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'} + ]; + + protected urlHelper = inject(UrlHelperService); + + copyMissing(): void { + Object.keys(this.fetchedMetadata).forEach((field) => { + if (!this.metadataForm.get(field)?.value && this.fetchedMetadata[field]) { + this.copyFetchedToCurrent(field); + } + }); + } + + copyAll() { + if (this.fetchedMetadata) { + Object.keys(this.fetchedMetadata).forEach((field) => { + if (this.fetchedMetadata[field] && field !== 'thumbnailUrl') { + this.copyFetchedToCurrent(field); + } + }); + } + } + + copyFetchedToCurrent(field: string): void { + const value = this.fetchedMetadata[field]; + if (value && !this.copiedFields[field]) { + this.metadataForm.get(field)?.setValue(value); + this.copiedFields[field] = true; + this.highlightCopiedInput(field); + this.metadataCopied.emit(true); + } + } + + highlightCopiedInput(field: string): void { + this.copiedFields[field] = true; + } + + isValueCopied(field: string): boolean { + return this.copiedFields[field]; + } + + isValueSaved(field: string): boolean { + return this.savedFields[field]; + } + + hoveredFields: { [key: string]: boolean } = {}; + + onMouseEnter(controlName: string): void { + if (this.isValueCopied(controlName) && !this.isValueSaved(controlName)) { + this.hoveredFields[controlName] = true; + } + } + + onMouseLeave(controlName: string): void { + this.hoveredFields[controlName] = false; + } + + resetField(field: string) { + this.metadataForm.get(field)?.setValue(this.originalMetadata[field]); + this.copiedFields[field] = false; + this.hoveredFields[field] = false; + } + + resetAll() { + if (this.originalMetadata) { + this.metadataForm.patchValue({ + title: this.originalMetadata.title || null, + subtitle: this.originalMetadata.subtitle || null, + authors: [...(this.originalMetadata.authors ?? [])].sort(), + categories: [...(this.originalMetadata.categories ?? [])].sort(), + publisher: this.originalMetadata.publisher || null, + publishedDate: this.originalMetadata.publishedDate || null, + isbn10: this.originalMetadata.isbn10 || null, + isbn13: this.originalMetadata.isbn13 || null, + description: this.originalMetadata.description || null, + pageCount: this.originalMetadata.pageCount || null, + language: this.originalMetadata.language || null, + asin: this.originalMetadata.asin || null, + amazonRating: this.originalMetadata.amazonRating || null, + amazonReviewCount: this.originalMetadata.amazonReviewCount || null, + goodreadsId: this.originalMetadata.goodreadsId || null, + goodreadsRating: this.originalMetadata.goodreadsRating || null, + goodreadsReviewCount: this.originalMetadata.goodreadsReviewCount || null, + hardcoverId: this.originalMetadata.hardcoverId || null, + hardcoverRating: this.originalMetadata.hardcoverRating || null, + hardcoverReviewCount: this.originalMetadata.hardcoverReviewCount || null, + googleId: this.originalMetadata.googleId || null, + seriesName: this.originalMetadata.seriesName || null, + seriesNumber: this.originalMetadata.seriesNumber || null, + seriesTotal: this.originalMetadata.seriesTotal || null, + thumbnailUrl: this.urlHelper.getBookdropCoverUrl(this.bookdropFileId), + }); + } + this.copiedFields = {}; + this.hoveredFields = {}; + this.metadataCopied.emit(false); + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.html b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.html new file mode 100644 index 00000000..22449490 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.html @@ -0,0 +1,270 @@ +
+ +
+

Review Bookdrop Files

+

+ These files were uploaded to the + Bookdrop Folder. + Review their fetched metadata, assign a library and subpath, and finalize where they belong in your collection. +

+
+ + @if (loading) { +
+
+ + + Loading Bookdrop files. Please wait... + +
+
+ } @else { +
+ @if (saving) { +
+ +
+ + Organizing and moving files to their designated libraries. Please wait... + +
+
+ } + + @if (bookdropFileUis.length !== 0) { +
+ + + +
+ +
+
+ Library for All Files: + + + + Subpath for All Files: + + + + + +
+ +
+ + + + + + + +
+
+ } +
+
+ @if (bookdropFileUis.length === 0) { +
+ No bookdrop files to review. +
+ } @else { + @for (file of bookdropFileUis; track file) { +
+
+ + @if (file.file.fetchedMetadata) { + + + } @else { + + + } + + @if (file.metadataForm.get('thumbnailUrl')?.value) { + Cover + } + + @if (file.file.fetchedMetadata?.thumbnailUrl) { + Cover + } + +
+ {{ file.file.fileName }} +
+ + @if (copiedFlags[file.file.id]) { + + + } @else if (!file.file.fetchedMetadata) { + + + } @else { + + + } + + + + + + + + + +
+ + + @if (file.showDetails) { + + + } +
+ } + } +
+ +
+ @if (bookdropFileUis.length !== 0) { + + + } + + + +
+ } +
diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.scss b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.scss new file mode 100644 index 00000000..ab9c5726 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.scss @@ -0,0 +1,12 @@ +.custom-border { + border: 1px solid var(--border-color); + border-radius: 10px 10px 0px 0px; +} + +.custom-border1 { + border-left: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + border-top: none; + border-radius: 0px 0px 10px 10px; +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.ts b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.ts new file mode 100644 index 00000000..df9dbacb --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-review-component/bookdrop-file-review.component.ts @@ -0,0 +1,354 @@ +import {Component, DestroyRef, inject, OnInit, QueryList, ViewChildren} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {filter, take} from 'rxjs/operators'; + +import {BookdropFile, BookdropFileTaskService, BookdropFinalizePayload, BookdropFinalizeResult} from '../bookdrop-file-task.service'; +import {LibraryService} from '../../book/service/library.service'; +import {Library} from '../../book/model/library.model'; + +import {ProgressSpinner} from 'primeng/progressspinner'; +import {DropdownModule} from 'primeng/dropdown'; +import {FormControl, FormGroup, FormsModule} from '@angular/forms'; +import {Button} from 'primeng/button'; +import {Select} from 'primeng/select'; +import {InputText} from 'primeng/inputtext'; +import {Tooltip} from 'primeng/tooltip'; +import {Divider} from 'primeng/divider'; +import {ConfirmationService, MessageService} from 'primeng/api'; + +import {BookdropFileMetadataPickerComponent} from '../bookdrop-file-metadata-picker-component/bookdrop-file-metadata-picker.component'; +import {Observable} from 'rxjs'; + +import {AppSettings} from '../../core/model/app-settings.model'; +import {AppSettingsService} from '../../core/service/app-settings.service'; +import {BookdropFinalizeResultDialogComponent} from '../bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component'; +import {DialogService} from 'primeng/dynamicdialog'; +import {BookMetadata} from '../../book/model/book.model'; +import {UrlHelperService} from '../../utilities/service/url-helper.service'; + +export interface BookdropFileUI { + file: BookdropFile; + metadataForm: FormGroup; + copiedFields: Record; + savedFields: Record; + selected: boolean; + showDetails: boolean; + selectedLibraryId: string | null; + selectedPathId: string | null; + availablePaths: { id: string; name: string }[]; +} + +@Component({ + selector: 'app-bookdrop-file-review-component', + standalone: true, + templateUrl: './bookdrop-file-review.component.html', + styleUrl: './bookdrop-file-review.component.scss', + imports: [ + ProgressSpinner, + DropdownModule, + FormsModule, + Button, + Select, + BookdropFileMetadataPickerComponent, + Tooltip, + Divider, + InputText, + + ], +}) +export class BookdropFileReviewComponent implements OnInit { + private readonly bookdropFileService = inject(BookdropFileTaskService); + private readonly libraryService = inject(LibraryService); + private readonly confirmationService = inject(ConfirmationService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogService = inject(DialogService); + private readonly appSettingsService = inject(AppSettingsService); + private readonly messageService = inject(MessageService); + + @ViewChildren('metadataPicker') metadataPickers!: QueryList; + + uploadPattern = ''; + defaultLibraryId: string | null = null; + defaultPathId: string | null = null; + bookdropFileUis: BookdropFileUI[] = []; + libraries: Library[] = []; + copiedFlags: Record = {}; + loading = true; + saving = false; + appSettings$: Observable = this.appSettingsService.appSettings$; + + protected urlHelper = inject(UrlHelperService); + + ngOnInit(): void { + this.bookdropFileService.getPendingFiles() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(files => { + this.bookdropFileUis = files.map(file => this.createFileUI(file)); + this.loading = false; + }); + + this.libraryService.libraryState$ + .pipe(filter(state => !!state?.loaded), take(1)) + .subscribe(state => { + this.libraries = state.libraries ?? []; + }); + + this.appSettings$ + .pipe(filter(Boolean), take(1)) + .subscribe(settings => { + this.uploadPattern = settings?.uploadPattern ?? ''; + }); + } + + get libraryOptions() { + return this.libraries.map(lib => ({label: lib.name, value: String(lib.id ?? '')})); + } + + get selectedLibraryPaths() { + const selectedLibrary = this.libraries.find(lib => String(lib.id) === this.defaultLibraryId); + return selectedLibrary?.paths.map(path => ({label: path.path, value: String(path.id ?? '')})) ?? []; + } + + onLibraryChange(file: BookdropFileUI): void { + const lib = this.libraries.find(l => String(l.id) === file.selectedLibraryId); + file.availablePaths = lib?.paths.map(p => ({id: String(p.id ?? ''), name: p.path})) ?? []; + file.selectedPathId = null; + } + + onMetadataCopied(fileId: number, copied: boolean): void { + this.copiedFlags[fileId] = copied; + } + + applyDefaultsToAll(): void { + if (!this.defaultLibraryId) return; + + const selectedLib = this.libraries.find(l => String(l.id) === this.defaultLibraryId); + const selectedPaths = selectedLib?.paths ?? []; + + for (const file of this.bookdropFileUis) { + file.selectedLibraryId = this.defaultLibraryId; + file.availablePaths = selectedPaths.map(path => ({id: String(path.id), name: path.path})); + file.selectedPathId = this.defaultPathId ?? null; + } + } + + get canApplyDefaults(): boolean { + return !!(this.defaultLibraryId && this.defaultPathId); + } + + copyAll(includeThumbnail: boolean): void { + for (const fileUi of this.bookdropFileUis) { + const fetched = fileUi.file.fetchedMetadata; + const form = fileUi.metadataForm; + if (!fetched) continue; + for (const key of Object.keys(fetched)) { + if (!includeThumbnail && key === 'thumbnailUrl') continue; + const value = fetched[key as keyof typeof fetched]; + if (value != null) { + form.get(key)?.setValue(value); + fileUi.copiedFields[key] = true; + } + } + this.onMetadataCopied(fileUi.file.id, true); + } + } + + resetAll(): void { + for (const fileUi of this.bookdropFileUis) { + const original = fileUi.file.originalMetadata; + fileUi.metadataForm.patchValue({ + title: original.title || null, + subtitle: original.subtitle || null, + authors: [...(original.authors ?? [])].sort(), + categories: [...(original.categories ?? [])].sort(), + publisher: original.publisher || null, + publishedDate: original.publishedDate || null, + isbn10: original.isbn10 || null, + isbn13: original.isbn13 || null, + description: original.description || null, + pageCount: original.pageCount || null, + language: original.language || null, + asin: original.asin || null, + amazonRating: original.amazonRating || null, + amazonReviewCount: original.amazonReviewCount || null, + goodreadsId: original.goodreadsId || null, + goodreadsRating: original.goodreadsRating || null, + goodreadsReviewCount: original.goodreadsReviewCount || null, + hardcoverId: original.hardcoverId || null, + hardcoverRating: original.hardcoverRating || null, + hardcoverReviewCount: original.hardcoverReviewCount || null, + googleId: original.googleId || null, + seriesName: original.seriesName || null, + seriesNumber: original.seriesNumber || null, + seriesTotal: original.seriesTotal || null, + thumbnailUrl: this.urlHelper.getBookdropCoverUrl(fileUi.file.id), + }); + fileUi.copiedFields = {}; + fileUi.savedFields = {}; + } + this.copiedFlags = {}; + } + + get canFinalize(): boolean { + return this.bookdropFileUis.length > 0 && + this.bookdropFileUis.every(file => file.selectedLibraryId && file.selectedPathId); + } + + confirmFinalize(): void { + this.confirmationService.confirm({ + message: 'Are you sure you want to finalize the import?', + header: 'Confirm Finalize', + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Yes', + rejectLabel: 'Cancel', + accept: () => this.finalizeImport(), + }); + } + + private finalizeImport(): void { + this.saving = true; + const payload = this.buildFinalizePayload(); + + this.bookdropFileService.finalizeImport(payload).subscribe({ + next: (result: BookdropFinalizeResult) => { + this.saving = false; + + this.messageService.add({ + severity: 'success', + summary: 'Import Complete', + detail: 'Import process finished. See details below.', + }); + + this.dialogService.open(BookdropFinalizeResultDialogComponent, { + header: 'Import Summary', + modal: true, + closable: true, + closeOnEscape: true, + data: { + results: result.results + } + }); + + this.reloadPendingFiles(); + }, + error: (err) => { + console.error('Error finalizing import:', err); + this.messageService.add({ + severity: 'error', + summary: 'Import Failed', + detail: 'Some files could not be moved. Please check the console for more details.', + }); + this.saving = false; + } + }); + } + + private buildFinalizePayload(): BookdropFinalizePayload { + return { + uploadPattern: this.uploadPattern, + files: this.bookdropFileUis.map((fileUi, index) => { + const rawMetadata = this.bookdropFileUis[index].metadataForm.value; + const metadata = {...rawMetadata}; + if (metadata.thumbnailUrl?.includes('/api/bookdrop/')) { + delete metadata.thumbnailUrl; + } + return { + fileId: fileUi.file.id, + libraryId: Number(fileUi.selectedLibraryId), + pathId: Number(fileUi.selectedPathId), + metadata, + }; + }), + }; + } + + private reloadPendingFiles(): void { + this.loading = true; + this.bookdropFileService.getPendingFiles() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: files => { + this.bookdropFileUis = files.map(file => this.createFileUI(file)); + this.loading = false; + this.saving = false; + }, + error: err => { + console.error('Error loading pending files:', err); + this.loading = false; + this.saving = false; + } + }); + } + + confirmDelete(): void { + this.confirmationService.confirm({ + message: 'Are you sure you want to delete all Bookdrop files? This action cannot be undone.', + header: 'Confirm Delete', + icon: 'pi pi-exclamation-triangle', + accept: () => { + this.bookdropFileService.discardAllFile().subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Files Deleted', + detail: 'All Bookdrop files were deleted successfully.', + }); + this.reloadPendingFiles(); + }, + error: (err) => { + this.messageService.add({ + severity: 'error', + summary: 'Delete Failed', + detail: 'An error occurred while deleting Bookdrop files.', + }); + }, + }); + }, + }); + } + + private createMetadataForm(original: BookMetadata, bookdropFileId: number): FormGroup { + return new FormGroup({ + title: new FormControl(original.title ?? ''), + subtitle: new FormControl(original.subtitle ?? ''), + authors: new FormControl([...(original.authors ?? [])].sort()), + categories: new FormControl([...(original.categories ?? [])].sort()), + publisher: new FormControl(original.publisher ?? ''), + publishedDate: new FormControl(original.publishedDate ?? ''), + isbn10: new FormControl(original.isbn10 ?? ''), + isbn13: new FormControl(original.isbn13 ?? ''), + description: new FormControl(original.description ?? ''), + pageCount: new FormControl(original.pageCount ?? ''), + language: new FormControl(original.language ?? ''), + asin: new FormControl(original.asin ?? ''), + amazonRating: new FormControl(original.amazonRating ?? ''), + amazonReviewCount: new FormControl(original.amazonReviewCount ?? ''), + goodreadsId: new FormControl(original.goodreadsId ?? ''), + goodreadsRating: new FormControl(original.goodreadsRating ?? ''), + goodreadsReviewCount: new FormControl(original.goodreadsReviewCount ?? ''), + hardcoverId: new FormControl(original.hardcoverId ?? ''), + hardcoverRating: new FormControl(original.hardcoverRating ?? ''), + hardcoverReviewCount: new FormControl(original.hardcoverReviewCount ?? ''), + googleId: new FormControl(original.googleId ?? ''), + seriesName: new FormControl(original.seriesName ?? ''), + seriesNumber: new FormControl(original.seriesNumber ?? ''), + seriesTotal: new FormControl(original.seriesTotal ?? ''), + thumbnailUrl: new FormControl(this.urlHelper.getBookdropCoverUrl(bookdropFileId)), + }); + } + + private createFileUI(file: BookdropFile): BookdropFileUI { + const metadataForm = this.createMetadataForm(file.originalMetadata, file.id); + return { + file, + selected: false, + showDetails: false, + selectedLibraryId: null, + selectedPathId: null, + availablePaths: [], + metadataForm, + copiedFields: {}, + savedFields: {} + }; + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts b/booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts new file mode 100644 index 00000000..4db35e33 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts @@ -0,0 +1,64 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {BookMetadata} from '../book/model/book.model'; +import {API_CONFIG} from '../config/api-config'; + +export enum BookdropFileStatus { + PENDING_REVIEW = 'PENDING_REVIEW', + ACCEPTED = 'ACCEPTED', + REJECTED = 'REJECTED', +} + +export interface BookdropFinalizePayload { + uploadPattern: string; + files: { + fileId: number; + libraryId: number; + pathId: number; + metadata: BookMetadata; + }[]; +} + +export interface BookdropFile { + showDetails: boolean; + id: number; + fileName: string; + filePath: string; + fileSize: number; + originalMetadata: BookMetadata; + fetchedMetadata?: BookMetadata; + createdAt: string; + updatedAt: string; + status: BookdropFileStatus; +} + +export interface BookdropFileResult { + fileName: string; + success: boolean; + message: string; +} + +export interface BookdropFinalizeResult { + results: BookdropFileResult[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class BookdropFileTaskService { + private readonly url = `${API_CONFIG.BASE_URL}/api/bookdrop`; + private http = inject(HttpClient); + + getPendingFiles(): Observable { + return this.http.get(`${this.url}/files?status=pending`); + } + + finalizeImport(payload: BookdropFinalizePayload): Observable { + return this.http.post(`${this.url}/imports/finalize`, payload); + } + + discardAllFile(): Observable { + return this.http.delete(`${this.url}/files`); + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-file.service.ts b/booklore-ui/src/app/bookdrop/bookdrop-file.service.ts new file mode 100644 index 00000000..85153586 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-file.service.ts @@ -0,0 +1,54 @@ +import {inject, Injectable, OnDestroy} from '@angular/core'; +import {BehaviorSubject, Subscription} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {BookdropFileApiService} from './bookdrop-file-api.service'; + +export interface BookdropFileNotification { + pendingCount: number; + totalCount: number; + lastUpdatedAt?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class BookdropFileService implements OnDestroy { + private summarySubject = new BehaviorSubject({ + pendingCount: 0, + totalCount: 0 + }); + + summary$ = this.summarySubject.asObservable(); + + hasPendingFiles$ = this.summary$.pipe( + map(summary => summary.pendingCount > 0) + ); + + private apiService = inject(BookdropFileApiService); + private subscriptions = new Subscription(); + + constructor() { + const sub = this.apiService.getNotification().subscribe({ + next: summary => this.summarySubject.next(summary), + error: err => console.warn('Failed to fetch bookdrop file summary:', err) + }); + this.subscriptions.add(sub); + } + + handleIncomingFile(summary: BookdropFileNotification): void { + this.summarySubject.next(summary); + } + + refresh(): void { + const sub = this.apiService.getNotification().subscribe({ + next: summary => this.summarySubject.next(summary), + error: err => console.warn('Failed to refresh bookdrop file summary:', err) + }); + this.subscriptions.add(sub); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + this.summarySubject.complete(); + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.html b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.html new file mode 100644 index 00000000..80a886bb --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.html @@ -0,0 +1,23 @@ +
+
+ +
+

Pending Bookdrop Files

+

{{ pendingCount }}

+ @if (lastUpdatedAt) { +

+ Last updated: {{ lastUpdatedAt | date: 'short' }} +

+ } +
+ +
+ +
+
+
diff --git a/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.scss b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.scss new file mode 100644 index 00000000..4af78b7d --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.scss @@ -0,0 +1,5 @@ +.staging-border { + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.ts b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.ts new file mode 100644 index 00000000..ad71c309 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component.ts @@ -0,0 +1,44 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {BookdropFileNotification, BookdropFileService} from '../bookdrop-file.service'; +import {DatePipe} from '@angular/common'; +import {Router} from '@angular/router'; + +@Component({ + selector: 'app-bookdrop-files-widget-component', + standalone: true, + templateUrl: './bookdrop-files-widget.component.html', + styleUrl: './bookdrop-files-widget.component.scss', + imports: [ + DatePipe + ] +}) +export class BookdropFilesWidgetComponent implements OnInit, OnDestroy { + pendingCount = 0; + totalCount = 0; + lastUpdatedAt?: string; + + private destroy$ = new Subject(); + private bookdropFileService = inject(BookdropFileService); + private router = inject(Router); + + ngOnInit(): void { + this.bookdropFileService.summary$ + .pipe(takeUntil(this.destroy$)) + .subscribe((summary: BookdropFileNotification) => { + this.pendingCount = summary.pendingCount; + this.totalCount = summary.totalCount; + this.lastUpdatedAt = summary.lastUpdatedAt; + }); + } + + openReviewDialog(): void { + this.router.navigate(['/bookdrop']); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.html b/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.html new file mode 100644 index 00000000..72486114 --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.html @@ -0,0 +1,26 @@ +
    + @for (result of results; track result) { +
  • + + +
    + + {{ result.fileName }} + + {{ result.message }} +
    +
  • + } +
diff --git a/booklore-ui/src/app/github-support-dialog/github-support-dialog.scss b/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.scss similarity index 100% rename from booklore-ui/src/app/github-support-dialog/github-support-dialog.scss rename to booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.scss diff --git a/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.ts b/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.ts new file mode 100644 index 00000000..8901892a --- /dev/null +++ b/booklore-ui/src/app/bookdrop/bookdrop-finalize-result-dialog-component/bookdrop-finalize-result-dialog-component.ts @@ -0,0 +1,25 @@ +import {Component, OnDestroy} from '@angular/core'; +import {NgClass} from '@angular/common'; +import {BookdropFileResult} from '../bookdrop-file-task.service'; +import {DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog"; + +@Component({ + selector: 'app-bookdrop-finalize-result-dialog-component', + imports: [ + NgClass + ], + templateUrl: './bookdrop-finalize-result-dialog-component.html', + styleUrl: './bookdrop-finalize-result-dialog-component.scss' +}) +export class BookdropFinalizeResultDialogComponent implements OnDestroy { + + results: BookdropFileResult[] = []; + + constructor(public ref: DynamicDialogRef, public config: DynamicDialogConfig) { + this.results = config.data.results; + } + + ngOnDestroy(): void { + this.ref?.close(); + } +} diff --git a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.html b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.html index b065ca44..59f219e8 100644 --- a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.html +++ b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.html @@ -1,4 +1,4 @@
-

{{ latestNotification.timestamp }}

+

{{ latestNotification.timestamp }}

{{ latestNotification.message }}

diff --git a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html index 0f031431..36547be2 100644 --- a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html +++ b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html @@ -1,7 +1,9 @@ diff --git a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.ts b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.ts index e8528e85..0bff98f7 100644 --- a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.ts +++ b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.ts @@ -4,13 +4,16 @@ import {MetadataProgressWidgetComponent} from '../metadata-progress-widget-compo import {MetadataProgressService} from '../../service/metadata-progress-service'; import {map} from 'rxjs/operators'; import {AsyncPipe} from '@angular/common'; +import {BookdropFilesWidgetComponent} from '../../../bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component'; +import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service'; @Component({ selector: 'app-unified-notification-popover-component', imports: [ LiveNotificationBoxComponent, MetadataProgressWidgetComponent, - AsyncPipe + AsyncPipe, + BookdropFilesWidgetComponent ], templateUrl: './unified-notification-popover-component.html', standalone: true, @@ -18,8 +21,11 @@ import {AsyncPipe} from '@angular/common'; }) export class UnifiedNotificationBoxComponent { metadataProgressService = inject(MetadataProgressService); + bookdropFileService = inject(BookdropFileService); hasMetadataTasks$ = this.metadataProgressService.activeTasks$.pipe( map(tasks => Object.keys(tasks).length > 0) ); + + hasPendingBookdropFiles$ = this.bookdropFileService.hasPendingFiles$; } diff --git a/booklore-ui/src/app/core/model/app-settings.model.ts b/booklore-ui/src/app/core/model/app-settings.model.ts index df589512..b51fe76b 100644 --- a/booklore-ui/src/app/core/model/app-settings.model.ts +++ b/booklore-ui/src/app/core/model/app-settings.model.ts @@ -90,6 +90,7 @@ export interface AppSettings { metadataProviderSettings: MetadataProviderSettings; metadataMatchWeights: MetadataMatchWeights; metadataPersistenceSettings: MetadataPersistenceSettings; + metadataDownloadOnBookdrop: boolean; } export enum AppSettingKey { @@ -108,5 +109,5 @@ export enum AppSettingKey { METADATA_MATCH_WEIGHTS = 'METADATA_MATCH_WEIGHTS', METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS', MOVE_FILE_PATTERN = 'MOVE_FILE_PATTERN', - BOOK_DELETION_ENABLED = 'BOOK_DELETION_ENABLED' + METADATA_DOWNLOAD_ON_BOOKDROP = 'METADATA_DOWNLOAD_ON_BOOKDROP' } diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts index e5629b0d..ed4cb77b 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts @@ -1,30 +1,31 @@ -import { Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; -import { MenuItem } from 'primeng/api'; -import { LayoutService } from '../layout-main/service/app.layout.service'; -import { Router, RouterLink } from '@angular/router'; -import { DialogService as PrimeDialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { LibraryCreatorComponent } from '../../../book/components/library-creator/library-creator.component'; -import { TooltipModule } from 'primeng/tooltip'; -import { FormsModule } from '@angular/forms'; -import { InputTextModule } from 'primeng/inputtext'; -import { BookSearcherComponent } from '../../../book/components/book-searcher/book-searcher.component'; -import { AsyncPipe, NgClass, NgStyle } from '@angular/common'; -import { NotificationEventService } from '../../../shared/websocket/notification-event.service'; -import { Button } from 'primeng/button'; -import { StyleClass } from 'primeng/styleclass'; -import { Divider } from 'primeng/divider'; -import { ThemeConfiguratorComponent } from '../theme-configurator/theme-configurator.component'; -import { BookUploaderComponent } from '../../../utilities/component/book-uploader/book-uploader.component'; -import { AuthService } from '../../../core/service/auth.service'; -import { UserService } from '../../../settings/user-management/user.service'; -import { UserProfileDialogComponent } from '../../../settings/global-preferences/user-profile-dialog/user-profile-dialog.component'; -import { GithubSupportDialog } from '../../../github-support-dialog/github-support-dialog'; -import { Popover } from 'primeng/popover'; -import { MetadataProgressService } from '../../../core/service/metadata-progress-service'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; -import { MetadataBatchProgressNotification } from '../../../core/model/metadata-batch-progress.model'; -import { UnifiedNotificationBoxComponent } from '../../../core/component/unified-notification-popover-component/unified-notification-popover-component'; +import {Component, ElementRef, OnDestroy, ViewChild} from '@angular/core'; +import {MenuItem} from 'primeng/api'; +import {LayoutService} from '../layout-main/service/app.layout.service'; +import {Router, RouterLink} from '@angular/router'; +import {DialogService as PrimeDialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {LibraryCreatorComponent} from '../../../book/components/library-creator/library-creator.component'; +import {TooltipModule} from 'primeng/tooltip'; +import {FormsModule} from '@angular/forms'; +import {InputTextModule} from 'primeng/inputtext'; +import {BookSearcherComponent} from '../../../book/components/book-searcher/book-searcher.component'; +import {AsyncPipe, NgClass, NgStyle} from '@angular/common'; +import {NotificationEventService} from '../../../shared/websocket/notification-event.service'; +import {Button} from 'primeng/button'; +import {StyleClass} from 'primeng/styleclass'; +import {Divider} from 'primeng/divider'; +import {ThemeConfiguratorComponent} from '../theme-configurator/theme-configurator.component'; +import {BookUploaderComponent} from '../../../utilities/component/book-uploader/book-uploader.component'; +import {AuthService} from '../../../core/service/auth.service'; +import {UserService} from '../../../settings/user-management/user.service'; +import {UserProfileDialogComponent} from '../../../settings/global-preferences/user-profile-dialog/user-profile-dialog.component'; +import {GithubSupportDialog} from '../../../utilities/component/github-support-dialog/github-support-dialog'; +import {Popover} from 'primeng/popover'; +import {MetadataProgressService} from '../../../core/service/metadata-progress-service'; +import {takeUntil} from 'rxjs/operators'; +import {Subject} from 'rxjs'; +import {MetadataBatchProgressNotification} from '../../../core/model/metadata-batch-progress.model'; +import {UnifiedNotificationBoxComponent} from '../../../core/component/unified-notification-popover-component/unified-notification-popover-component'; +import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service'; @Component({ selector: 'app-topbar', @@ -62,10 +63,14 @@ export class AppTopBarComponent implements OnDestroy { hasActiveOrCompletedTasks = false; showPulse = false; hasAnyTasks = false; + hasPendingBookdropFiles = false; private eventTimer: any; private destroy$ = new Subject(); + private latestTasks: { [taskId: string]: MetadataBatchProgressNotification } = {}; + private latestHasPendingFiles = false; + constructor( public layoutService: LayoutService, public dialogService: PrimeDialogService, @@ -73,7 +78,8 @@ export class AppTopBarComponent implements OnDestroy { private router: Router, private authService: AuthService, protected userService: UserService, - private metadataProgressService: MetadataProgressService + private metadataProgressService: MetadataProgressService, + private bookdropFileService: BookdropFileService ) { this.subscribeToMetadataProgress(); this.subscribeToNotifications(); @@ -81,10 +87,20 @@ export class AppTopBarComponent implements OnDestroy { this.metadataProgressService.activeTasks$ .pipe(takeUntil(this.destroy$)) .subscribe((tasks) => { + this.latestTasks = tasks; this.hasAnyTasks = Object.keys(tasks).length > 0; - this.updateCompletedTaskCount(tasks); + this.updateCompletedTaskCount(); this.updateTaskVisibility(tasks); }); + + this.bookdropFileService.hasPendingFiles$ + .pipe(takeUntil(this.destroy$)) + .subscribe((hasPending) => { + this.latestHasPendingFiles = hasPending; + this.hasPendingBookdropFiles = hasPending; + this.updateCompletedTaskCount(); + this.updateTaskVisibilityWithBookdrop(); + }); } ngOnDestroy(): void { @@ -156,40 +172,45 @@ export class AppTopBarComponent implements OnDestroy { }, 4000); } - private updateCompletedTaskCount(tasks: { [taskId: string]: MetadataBatchProgressNotification }) { - this.completedTaskCount = Object.values(tasks).filter(task => task.status === 'COMPLETED').length; + private updateCompletedTaskCount() { + const completedMetadataTasks = Object.values(this.latestTasks).filter(task => task.status === 'COMPLETED').length; + const bookdropFileTaskCount = this.latestHasPendingFiles ? 1 : 0; + this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount; } private updateTaskVisibility(tasks: { [taskId: string]: MetadataBatchProgressNotification }) { this.hasActiveOrCompletedTasks = this.progressHighlight || this.completedTaskCount > 0 || Object.keys(tasks).length > 0; + this.updateTaskVisibilityWithBookdrop(); + } + + private updateTaskVisibilityWithBookdrop() { + this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasPendingBookdropFiles; } get iconClass(): string { - if (!this.hasAnyTasks) return 'pi-wave-pulse'; if (this.progressHighlight) return 'pi-spinner spin'; - if (this.showPulse) return 'pi-wave-pulse'; - if (this.completedTaskCount > 0) return 'pi-bell'; + if (this.iconPulsating) return 'pi-wave-pulse'; + if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles) return 'pi-bell'; return 'pi-wave-pulse'; } get iconColor(): string { if (this.progressHighlight) return 'yellow'; if (this.showPulse) return 'red'; - if (this.completedTaskCount > 0) return 'red'; - return 'inherit'; // Default to theme/parent styling + if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles) return 'orange'; + return 'inherit'; } get iconPulsating(): boolean { - return !this.progressHighlight && this.showPulse; + return !this.progressHighlight && (this.showPulse); } get shouldShowNotificationBadge(): boolean { return ( - this.completedTaskCount > 0 && + (this.completedTaskCount > 0 || this.hasPendingBookdropFiles) && !this.progressHighlight && - !this.showPulse && - this.hasAnyTasks + !this.showPulse ); } } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/book-metadata-center.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/book-metadata-center.component.ts index 3ab45d12..efa035c6 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/book-metadata-center.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/book-metadata-center.component.ts @@ -26,7 +26,7 @@ import { MetadataViewerComponent } from './metadata-viewer/metadata-viewer.compo import { MetadataEditorComponent } from './metadata-editor/metadata-editor.component'; import { MetadataSearcherComponent } from './metadata-searcher/metadata-searcher.component'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { BookMetadataHostService } from '../../book-metadata-host-service'; +import { BookMetadataHostService } from '../../utilities/service/book-metadata-host-service'; @Component({ selector: 'app-book-metadata-center', diff --git a/booklore-ui/src/app/metadata/metadata-review-dialog-component/metadata-review-dialog-component.ts b/booklore-ui/src/app/metadata/metadata-review-dialog-component/metadata-review-dialog-component.ts index dc41ab92..68aa969c 100644 --- a/booklore-ui/src/app/metadata/metadata-review-dialog-component/metadata-review-dialog-component.ts +++ b/booklore-ui/src/app/metadata/metadata-review-dialog-component/metadata-review-dialog-component.ts @@ -1,7 +1,7 @@ import {Component, DestroyRef, inject, OnInit, ViewChild} from '@angular/core'; import {CommonModule} from '@angular/common'; import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; -import {FetchedProposalDto, MetadataTaskService} from '../../book/service/metadata-task'; +import {FetchedProposal, MetadataTaskService} from '../../book/service/metadata-task'; import {BookService} from '../../book/service/book.service'; import {Book} from '../../book/model/book.model'; import {BehaviorSubject, Observable} from 'rxjs'; @@ -34,7 +34,7 @@ export class MetadataReviewDialogComponent implements OnInit { private progressService = inject(MetadataProgressService); private destroyRef = inject(DestroyRef); - proposals: FetchedProposalDto[] = []; + proposals: FetchedProposal[] = []; currentBooks: Record = {}; loading = true; currentIndex = 0; @@ -89,7 +89,7 @@ export class MetadataReviewDialogComponent implements OnInit { }); } - get currentProposal(): FetchedProposalDto | null { + get currentProposal(): FetchedProposal | null { return this.proposals[this.currentIndex] ?? null; } @@ -118,7 +118,7 @@ export class MetadataReviewDialogComponent implements OnInit { }); } - onSave(updatedFields: Partial): void { + onSave(updatedFields: Partial): void { const currentProposal = this.currentProposal; if (!currentProposal) return; diff --git a/booklore-ui/src/app/settings/global-preferences/metadata-match-weights-component/metadata-match-weights-component.ts b/booklore-ui/src/app/settings/global-preferences/metadata-match-weights-component/metadata-match-weights-component.ts index d3814d00..d53a9a54 100644 --- a/booklore-ui/src/app/settings/global-preferences/metadata-match-weights-component/metadata-match-weights-component.ts +++ b/booklore-ui/src/app/settings/global-preferences/metadata-match-weights-component/metadata-match-weights-component.ts @@ -1,7 +1,7 @@ import {Component, inject, OnInit} from '@angular/core'; import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {MessageService} from 'primeng/api'; -import {MetadataMatchWeightsService} from '../../../metadata-match-weights-service'; +import {MetadataMatchWeightsService} from '../../../utilities/service/metadata-match-weights-service'; import {Button} from 'primeng/button'; import {InputText} from 'primeng/inputtext'; import {Tooltip} from 'primeng/tooltip'; diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.html b/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.html index c604c689..702dd236 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.html +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.html @@ -1,5 +1,29 @@
+
+

Auto-Download Metadata for Files in BookDrop Folder:

+ +
+ +
+ + +
+ + + Automatically downloads metadata from your configured sources (Amazon, Goodreads, etc.) when files are added to the Bookdrop folder. Use with caution if adding many files at once as metadata fetching can take time. + +
+
+
+
+ +
+ +
+

Metadata Persistence:

diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.ts b/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.ts index e3402439..fd03cf32 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.ts +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-settings-component.ts @@ -1,17 +1,17 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { Divider } from 'primeng/divider'; -import { MetadataAdvancedFetchOptionsComponent } from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component'; -import { MetadataProviderSettingsComponent } from '../global-preferences/metadata-provider-settings/metadata-provider-settings.component'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Tooltip } from 'primeng/tooltip'; -import { MetadataRefreshOptions } from '../../metadata/model/request/metadata-refresh-options.model'; -import { AppSettingsService } from '../../core/service/app-settings.service'; -import { MessageService } from 'primeng/api'; -import { Observable } from 'rxjs'; -import { AppSettingKey, AppSettings, MetadataPersistenceSettings } from '../../core/model/app-settings.model'; -import { filter, take } from 'rxjs/operators'; -import { MetadataMatchWeightsComponent } from '../global-preferences/metadata-match-weights-component/metadata-match-weights-component'; -import { ToggleSwitch } from 'primeng/toggleswitch'; +import {Component, inject, OnInit} from '@angular/core'; +import {Divider} from 'primeng/divider'; +import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component'; +import {MetadataProviderSettingsComponent} from '../global-preferences/metadata-provider-settings/metadata-provider-settings.component'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {Tooltip} from 'primeng/tooltip'; +import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model'; +import {AppSettingsService} from '../../core/service/app-settings.service'; +import {MessageService} from 'primeng/api'; +import {Observable} from 'rxjs'; +import {AppSettingKey, AppSettings, MetadataPersistenceSettings} from '../../core/model/app-settings.model'; +import {filter, take} from 'rxjs/operators'; +import {MetadataMatchWeightsComponent} from '../global-preferences/metadata-match-weights-component/metadata-match-weights-component'; +import {ToggleSwitch} from 'primeng/toggleswitch'; @Component({ selector: 'app-metadata-settings-component', @@ -37,6 +37,7 @@ export class MetadataSettingsComponent implements OnInit { backupMetadata: true, backupCover: true }; + metadataDownloadOnBookdrop: boolean = true; private appSettingsService = inject(AppSettingsService); private messageService = inject(MessageService); @@ -52,11 +53,16 @@ export class MetadataSettingsComponent implements OnInit { this.currentMetadataOptions = settings.metadataRefreshOptions; } if (settings?.metadataPersistenceSettings) { - this.metadataPersistence = { ...settings.metadataPersistenceSettings }; + this.metadataPersistence = {...settings.metadataPersistenceSettings}; } + this.metadataDownloadOnBookdrop = settings?.metadataDownloadOnBookdrop; }); } + onMetadataDownloadOnBookdropToggle(checked: boolean) { + this.saveSetting(AppSettingKey.METADATA_DOWNLOAD_ON_BOOKDROP, checked); + } + onPersistenceToggle(key: keyof MetadataPersistenceSettings): void { if (key === 'saveToOriginalFile') { this.metadataPersistence.saveToOriginalFile = !this.metadataPersistence.saveToOriginalFile; @@ -77,7 +83,7 @@ export class MetadataSettingsComponent implements OnInit { } private saveSetting(key: string, value: unknown): void { - this.appSettingsService.saveSettings([{ key, newValue: value }]).subscribe({ + this.appSettingsService.saveSettings([{key, newValue: value}]).subscribe({ next: () => this.showMessage('success', 'Settings Saved', 'The settings were successfully saved!'), error: () => @@ -86,6 +92,8 @@ export class MetadataSettingsComponent implements OnInit { } private showMessage(severity: 'success' | 'error', summary: string, detail: string): void { - this.messageService.add({ severity, summary, detail }); + this.messageService.add({severity, summary, detail}); } + + protected readonly AppSettingKey = AppSettingKey; } diff --git a/booklore-ui/src/app/file-mover-component/file-mover-component.html b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html similarity index 100% rename from booklore-ui/src/app/file-mover-component/file-mover-component.html rename to booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html diff --git a/booklore-ui/src/app/file-mover-component/file-mover-component.scss b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.scss similarity index 100% rename from booklore-ui/src/app/file-mover-component/file-mover-component.scss rename to booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.scss diff --git a/booklore-ui/src/app/file-mover-component/file-mover-component.ts b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts similarity index 94% rename from booklore-ui/src/app/file-mover-component/file-mover-component.ts rename to booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts index ee93f8bf..2142bd65 100644 --- a/booklore-ui/src/app/file-mover-component/file-mover-component.ts +++ b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts @@ -8,12 +8,12 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; import {MessageService} from 'primeng/api'; import {filter, take} from 'rxjs/operators'; -import {BookService} from '../book/service/book.service'; -import {Book} from '../book/model/book.model'; -import {FileMoveRequest, FileOperationsService} from '../file-operations-service'; -import {LibraryService} from "../book/service/library.service"; -import {AppSettingsService} from '../core/service/app-settings.service'; -import {AppSettingKey} from '../core/model/app-settings.model'; +import {BookService} from '../../../book/service/book.service'; +import {Book} from '../../../book/model/book.model'; +import {FileMoveRequest, FileOperationsService} from '../../service/file-operations-service'; +import {LibraryService} from "../../../book/service/library.service"; +import {AppSettingsService} from '../../../core/service/app-settings.service'; +import {AppSettingKey} from '../../../core/model/app-settings.model'; @Component({ selector: 'app-file-mover-component', diff --git a/booklore-ui/src/app/github-support-dialog/github-support-dialog.html b/booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.html similarity index 100% rename from booklore-ui/src/app/github-support-dialog/github-support-dialog.html rename to booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.html diff --git a/booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.scss b/booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.scss new file mode 100644 index 00000000..e69de29b diff --git a/booklore-ui/src/app/github-support-dialog/github-support-dialog.ts b/booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.ts similarity index 100% rename from booklore-ui/src/app/github-support-dialog/github-support-dialog.ts rename to booklore-ui/src/app/utilities/component/github-support-dialog/github-support-dialog.ts diff --git a/booklore-ui/src/app/book-metadata-host-service.ts b/booklore-ui/src/app/utilities/service/book-metadata-host-service.ts similarity index 100% rename from booklore-ui/src/app/book-metadata-host-service.ts rename to booklore-ui/src/app/utilities/service/book-metadata-host-service.ts diff --git a/booklore-ui/src/app/file-operations-service.ts b/booklore-ui/src/app/utilities/service/file-operations-service.ts similarity index 90% rename from booklore-ui/src/app/file-operations-service.ts rename to booklore-ui/src/app/utilities/service/file-operations-service.ts index 2044f841..a1674f7b 100644 --- a/booklore-ui/src/app/file-operations-service.ts +++ b/booklore-ui/src/app/utilities/service/file-operations-service.ts @@ -1,7 +1,7 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {API_CONFIG} from './config/api-config'; +import {API_CONFIG} from '../../config/api-config'; export interface FileMoveRequest { bookIds: number[]; diff --git a/booklore-ui/src/app/metadata-match-weights-service.ts b/booklore-ui/src/app/utilities/service/metadata-match-weights-service.ts similarity index 94% rename from booklore-ui/src/app/metadata-match-weights-service.ts rename to booklore-ui/src/app/utilities/service/metadata-match-weights-service.ts index 23223974..68f5e92b 100644 --- a/booklore-ui/src/app/metadata-match-weights-service.ts +++ b/booklore-ui/src/app/utilities/service/metadata-match-weights-service.ts @@ -1,7 +1,7 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {API_CONFIG} from './config/api-config'; +import {API_CONFIG} from '../../config/api-config'; export interface MetadataMatchWeights { title: number; diff --git a/booklore-ui/src/app/utilities/service/url-helper.service.ts b/booklore-ui/src/app/utilities/service/url-helper.service.ts index 97e72667..dd79fc23 100644 --- a/booklore-ui/src/app/utilities/service/url-helper.service.ts +++ b/booklore-ui/src/app/utilities/service/url-helper.service.ts @@ -17,4 +17,8 @@ export class UrlHelperService { getBackupCoverUrl(bookId: number): string { return `${this.baseUrl}/api/v1/books/${bookId}/backup-cover`; } + + getBookdropCoverUrl(bookdropId: number): string { + return `${this.baseUrl}/api/bookdrop/${bookdropId}/cover`; + } }