mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Implement Bookdrop: Watch folder for file drops and auto-process uploads
This commit is contained in:
committed by
Aditya Chandel
parent
5064706b8f
commit
177528e640
45
README.md
45
README.md
@@ -28,7 +28,6 @@ For a step-by-step walkthrough, check out the official BookLore video guides on
|
||||
|
||||
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 @@ services:
|
||||
- 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 @@ services:
|
||||
- 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 @@ Once the containers are up, access BookLore in your browser at:
|
||||
```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.)
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<BookdropFile> 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<Void> discardAllFiles() {
|
||||
bookDropService.discardAllFiles();
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/imports/finalize")
|
||||
public ResponseEntity<BookdropFinalizeResult> finalizeImport(@RequestBody BookdropFinalizeRequest request) {
|
||||
BookdropFinalizeResult result = bookDropService.finalizeImport(request);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{bookdropId}/cover")
|
||||
public ResponseEntity<Resource> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BookdropFinalizeFile> files;
|
||||
|
||||
@Data
|
||||
public static class BookdropFinalizeFile {
|
||||
private Long fileId;
|
||||
private Long libraryId;
|
||||
private Long pathId;
|
||||
private BookMetadata metadata;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BookdropFileResult> results = new ArrayList<>();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<BookdropFileEntity, Long> {
|
||||
|
||||
Optional<BookdropFileEntity> findByFilePath(String filePath);
|
||||
|
||||
List<BookdropFileEntity> 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<Path> 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<Path> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BookDropFileEvent> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MetadataProvider> 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<MetadataProvider, BookMetadata> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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<Path> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MetadataProvider> prepareProviders(MetadataRefreshRequest request) {
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshRequest request) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(request));
|
||||
return new ArrayList<>(allProviders);
|
||||
}
|
||||
@@ -231,6 +234,23 @@ public class MetadataRefreshService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<MetadataProvider, BookMetadata> fetchMetadataForBook(List<MetadataProvider> 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<MetadataProvider, BookMetadata> fetchMetadataForBook(List<MetadataProvider> 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<MetadataProvider, BookMetadata> metadataMap) {
|
||||
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshRequest request, Map<MetadataProvider, BookMetadata> metadataMap) {
|
||||
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,6 @@ import java.io.File;
|
||||
public interface FileMetadataExtractor {
|
||||
|
||||
BookMetadata extractMetadata(File file);
|
||||
|
||||
byte[] extractCover(File file);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
app:
|
||||
path-config: '/app/data'
|
||||
bookdrop-folder: '/bookdrop'
|
||||
version: 'v0.0.40'
|
||||
swagger:
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<BookdropFileNotification> 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
18
booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts
Normal file
18
booklore-ui/src/app/bookdrop/bookdrop-file-api.service.ts
Normal file
@@ -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<BookdropFileNotification> {
|
||||
return this.http.get<BookdropFileNotification>(`${this.url}/notification`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
@if (fetchedMetadata) {
|
||||
<form [formGroup]="metadataForm" class="flex flex-col h-[30rem] w-full">
|
||||
<div class="flex-grow overflow-auto">
|
||||
<div class="relative flex items-center justify-between pb-3">
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 flex items-center pl-24">
|
||||
<p class="pr-6">Current Metadata</p>
|
||||
<div>
|
||||
<p-button
|
||||
severity="success"
|
||||
icon="pi pi-angle-left"
|
||||
class="mx-2"
|
||||
[outlined]="true"
|
||||
pTooltip="Move all missing fields"
|
||||
tooltipPosition="bottom"
|
||||
(onClick)="copyMissing()"
|
||||
></p-button>
|
||||
<p-button
|
||||
severity="success"
|
||||
icon="pi pi-angle-double-left"
|
||||
class="mx-2"
|
||||
[outlined]="true"
|
||||
pTooltip="Move all fields"
|
||||
tooltipPosition="bottom"
|
||||
(onClick)="copyAll()"
|
||||
></p-button>
|
||||
</div>
|
||||
<p class="pl-6">Fetched Metadata</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<p-button
|
||||
severity="danger"
|
||||
icon="pi pi-refresh"
|
||||
label="Reset"
|
||||
[outlined]="true"
|
||||
pTooltip="Reset all fields to original values"
|
||||
tooltipPosition="bottom"
|
||||
(onClick)="resetAll()"
|
||||
></p-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center py-1">
|
||||
<label class="w-[6.5%]"></label>
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<img [src]="metadataForm.get('thumbnailUrl')?.value" alt="Book Thumbnail" class="thumbnail"/>
|
||||
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl" class="!w-1/2"/>
|
||||
<p-button
|
||||
[icon]="isValueSaved('thumbnailUrl') ? 'pi pi-check' : (hoveredFields['thumbnailUrl'] && isValueCopied('thumbnailUrl') ? 'pi pi-times' : 'pi pi-arrow-left')"
|
||||
[outlined]="true"
|
||||
[ngClass]="
|
||||
{
|
||||
'green-outlined-button': isValueCopied('thumbnailUrl') && !hoveredFields['thumbnailUrl'],
|
||||
'red-outlined-button': isValueCopied('thumbnailUrl') && hoveredFields['thumbnailUrl']
|
||||
}"
|
||||
class="arrow-button"
|
||||
(click)="hoveredFields['thumbnailUrl'] && isValueCopied('thumbnailUrl') ? resetField('thumbnailUrl') : copyFetchedToCurrent('thumbnailUrl')"
|
||||
(mouseenter)="onMouseEnter('thumbnailUrl')"
|
||||
(mouseleave)="onMouseLeave('thumbnailUrl')"/>
|
||||
<input type="hidden" [value]="fetchedMetadata.thumbnailUrl" class="!w-1/2" readonly/>
|
||||
<img [src]="fetchedMetadata.thumbnailUrl ?? null" alt="Fetched Thumbnail" class="thumbnail"/>
|
||||
</div>
|
||||
</div>
|
||||
@for (field of metadataFieldsTop; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[6.5%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
pSize="small"
|
||||
fluid
|
||||
pInputText
|
||||
id="{{field.controlName}}"
|
||||
formControlName="{{field.controlName}}"
|
||||
class="!w-1/2"
|
||||
[ngClass]="{
|
||||
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
}"
|
||||
/>
|
||||
<p-button
|
||||
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
|
||||
[outlined]="true"
|
||||
[ngClass]="
|
||||
{
|
||||
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
|
||||
}"
|
||||
class="arrow-button"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"/>
|
||||
<input pSize="small" pInputText [value]="fetchedMetadata[field.fetchedKey] ?? null" class="!w-1/2" readonly/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (field of metadataChips; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[6.5%]">{{ field.label }}</label>
|
||||
<div class="flex w-full items-center">
|
||||
<div class="w-full"
|
||||
[ngClass]="{
|
||||
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
}">
|
||||
<p-chips formControlName="{{field.controlName}}" addOnBlur="true"></p-chips>
|
||||
</div>
|
||||
<p-button
|
||||
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
|
||||
[outlined]="true"
|
||||
[ngClass]="
|
||||
{
|
||||
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
|
||||
}"
|
||||
class="arrow-button"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"/>
|
||||
|
||||
<div class="w-full">
|
||||
<p-chips
|
||||
[ngModel]="fetchedMetadata[field.fetchedKey] ?? []"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="true">
|
||||
</p-chips>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (field of metadataDescription; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[6.5%]">{{ field.label }}</label>
|
||||
<div class="flex w-full items-center">
|
||||
<textarea rows="2" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}" class="!w-1/2"
|
||||
[ngClass]="{
|
||||
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
}"
|
||||
></textarea>
|
||||
<p-button
|
||||
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
|
||||
[outlined]="true"
|
||||
[ngClass]="
|
||||
{
|
||||
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
|
||||
}"
|
||||
class="arrow-button"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"/>
|
||||
<textarea rows="2" pInputText [value]="fetchedMetadata[field.fetchedKey] ?? null" class="!w-1/2" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (field of metadataFieldsBottom; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[6.5%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<input pInputText pSize="small" id="{{field.controlName}}" formControlName="{{field.controlName}}" class="!w-1/2"
|
||||
[ngClass]="{
|
||||
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
}"
|
||||
/>
|
||||
<p-button
|
||||
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
|
||||
[outlined]="true"
|
||||
[ngClass]="
|
||||
{
|
||||
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
|
||||
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
|
||||
}"
|
||||
class="arrow-button"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"/>
|
||||
<input pInputText pSize="small" [value]="fetchedMetadata[field.fetchedKey] ?? null" class="!w-1/2" readonly/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<form [formGroup]="metadataForm" class="flex h-[30rem] w-full">
|
||||
<div class="flex-grow overflow-auto pr-4">
|
||||
<p class="pb-2">Current Metadata:</p>
|
||||
|
||||
@for (field of metadataFieldsTop; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[15%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
pSize="small"
|
||||
fluid
|
||||
pInputText
|
||||
id="{{field.controlName}}"
|
||||
formControlName="{{field.controlName}}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (field of metadataChips; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[15%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<p-chips class="w-full" formControlName="{{field.controlName}}" addOnBlur="true"></p-chips>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (field of metadataDescription; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[15%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<textarea fluid rows="2" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (field of metadataFieldsBottom; track field) {
|
||||
<div class="flex items-center py-1">
|
||||
<label for="{{field.controlName}}" class="w-[15%]">{{ field.label }}</label>
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
fluid
|
||||
pInputText
|
||||
pSize="small"
|
||||
id="{{field.controlName}}"
|
||||
formControlName="{{field.controlName}}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="w-[20%] h-full flex items-start justify-center p-4">
|
||||
<div class="w-full h-full">
|
||||
<img
|
||||
[src]="metadataForm.get('thumbnailUrl')?.value"
|
||||
alt="Book Thumbnail"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl"/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, boolean> = {};
|
||||
@Input() savedFields: Record<string, boolean> = {};
|
||||
@Input() bookdropFileId!: number;
|
||||
|
||||
@Output() metadataCopied = new EventEmitter<boolean>();
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<div class="flex flex-col h-[calc(100vh-6.1rem)] rounded-xl bg-[var(--card-background)] space-y-4">
|
||||
|
||||
<div class="px-6 pt-6 pb-4">
|
||||
<h2 class="text-xl font-semibold pb-1">Review Bookdrop Files</h2>
|
||||
<p class="text-sm text-gray-400">
|
||||
These files were uploaded to the
|
||||
<strong class="text-[var(--primary-color)]">Bookdrop Folder</strong>.
|
||||
Review their fetched metadata, assign a library and subpath, and finalize where they belong in your collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="absolute inset-0 flex items-center justify-center z-50 rounded-xl">
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<p-progressSpinner styleClass="w-8 h-8" strokeWidth="4"/>
|
||||
<span class="text-gray-300">
|
||||
Loading Bookdrop files. Please wait...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="px-6 pb-2">
|
||||
@if (saving) {
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-50 rounded-xl space-y-3">
|
||||
<p-progressSpinner styleClass="w-8 h-8" strokeWidth="4"/>
|
||||
<div class="bg-gray-900/90 px-4 py-2 rounded-md">
|
||||
<span class="text-sm text-gray-200 text-center">
|
||||
Organizing and moving files to their designated libraries. Please wait...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (bookdropFileUis.length !== 0) {
|
||||
<div class="flex gap-2 items-center px-1 pb-4 w-full">
|
||||
<label for="uploadPatternInput" class="text-sm text-gray-300 font-medium">File Naming Pattern:</label>
|
||||
<input
|
||||
id="uploadPatternInput"
|
||||
fluid
|
||||
class="min-w-[20rem] max-w-[45.5rem]"
|
||||
pSize="small"
|
||||
type="text"
|
||||
pInputText
|
||||
[(ngModel)]="uploadPattern"
|
||||
placeholder="e.g., {title} - {authors}"
|
||||
/>
|
||||
<i
|
||||
class="pi pi-info-circle text-blue-500 cursor-help"
|
||||
pTooltip="Sets the filename pattern used when moving files from bookdrop folder to the library, using metadata placeholders like {title}, {authors}, etc."
|
||||
tooltipPosition="top"
|
||||
style="font-size: 1rem; margin-left: 0.25rem;"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-4 px-1">
|
||||
<div class="flex gap-4 items-center">
|
||||
<span class="text-sm text-gray-300 font-medium">Library for All Files:</span>
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="libraryOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select Default Library"
|
||||
class="min-w-[8rem] max-w-[16rem]"
|
||||
[(ngModel)]="defaultLibraryId">
|
||||
</p-select>
|
||||
|
||||
<span class="text-sm text-gray-300 font-medium">Subpath for All Files:</span>
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="selectedLibraryPaths"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select Default Subpath"
|
||||
class="min-w-[8rem] max-w-[16rem]"
|
||||
[(ngModel)]="defaultPathId">
|
||||
</p-select>
|
||||
|
||||
<p-button
|
||||
size="small"
|
||||
label="Apply to All"
|
||||
icon="pi pi-check"
|
||||
[disabled]="!canApplyDefaults"
|
||||
(click)="applyDefaultsToAll()"
|
||||
pTooltip="Apply selected library and subpath to all files"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<p-button
|
||||
size="small"
|
||||
outlined
|
||||
severity="info"
|
||||
label="Apply (w/ Cover)"
|
||||
icon="pi pi-copy"
|
||||
(click)="copyAll(true)"
|
||||
pTooltip="For all files, replace current metadata with fetched metadata, including cover images"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
|
||||
<p-button
|
||||
size="small"
|
||||
outlined
|
||||
severity="info"
|
||||
label="Apply (no Cover)"
|
||||
icon="pi pi-copy"
|
||||
(click)="copyAll(false)"
|
||||
pTooltip="For all files, replace current metadata with fetched metadata, excluding cover images"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
<p-button
|
||||
size="small"
|
||||
outlined
|
||||
severity="warn"
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
(click)="resetAll()"
|
||||
pTooltip="Reset all metadata changes"
|
||||
tooltipPosition="left">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 space-y-2 pb-4">
|
||||
@if (bookdropFileUis.length === 0) {
|
||||
<div class="h-full w-full flex items-center justify-center text-gray-400 italic py-8">
|
||||
No bookdrop files to review.
|
||||
</div>
|
||||
} @else {
|
||||
@for (file of bookdropFileUis; track file) {
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-4 custom-border rounded-xl px-4 py-2">
|
||||
|
||||
@if (file.file.fetchedMetadata) {
|
||||
<i
|
||||
class="pi pi-circle-fill"
|
||||
style="color: green; font-size: 0.75rem"
|
||||
pTooltip="Fetched metadata is available."
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
} @else {
|
||||
<i
|
||||
class="pi pi-circle-fill"
|
||||
style="color: darkorange; font-size: 0.75rem"
|
||||
pTooltip="No fetched metadata available."
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
|
||||
@if (file.metadataForm.get('thumbnailUrl')?.value) {
|
||||
<img
|
||||
[src]="file.metadataForm.get('thumbnailUrl')?.value"
|
||||
alt="Cover"
|
||||
title="Original cover"
|
||||
(click)="file.showDetails = !file.showDetails"
|
||||
class="w-6 h-8 flex-shrink-0 rounded-sm object-cover cursor-pointer transition-transform duration-200 hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (file.file.fetchedMetadata?.thumbnailUrl) {
|
||||
<img
|
||||
[src]="file.file.fetchedMetadata?.thumbnailUrl"
|
||||
alt="Cover"
|
||||
title="Fetched cover"
|
||||
(click)="file.showDetails = !file.showDetails"
|
||||
class="w-6 h-8 flex-shrink-0 rounded-sm object-cover cursor-pointer transition-transform duration-200 hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="flex-1 font-medium text-sm truncate" (click)="file.showDetails = !file.showDetails">
|
||||
{{ file.file.fileName }}
|
||||
</div>
|
||||
|
||||
@if (copiedFlags[file.file.id]) {
|
||||
<i
|
||||
class="pi pi-check-circle text-green-500"
|
||||
pTooltip="Fetched metadata has been applied."
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
} @else if (!file.file.fetchedMetadata) {
|
||||
<i
|
||||
class="pi pi-check-circle text-blue-500"
|
||||
pTooltip="No fetched metadata available. Original metadata will used."
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
} @else {
|
||||
<i
|
||||
class="pi pi-exclamation-triangle text-red-500"
|
||||
pTooltip="Fetched metadata hasn’t been applied yet. You can open the metadata picker using the dropdown on the right, or skip and keep the original metadata."
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="libraryOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select Library"
|
||||
class="min-w-[8rem] max-w-[16rem]"
|
||||
[(ngModel)]="file.selectedLibraryId"
|
||||
(onChange)="onLibraryChange(file)">
|
||||
</p-select>
|
||||
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="file.availablePaths"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="Select Subpath"
|
||||
class="min-w-[8rem] max-w-[16rem]"
|
||||
appendTo="body"
|
||||
[(ngModel)]="file.selectedPathId">
|
||||
</p-select>
|
||||
|
||||
<p-button
|
||||
size="small"
|
||||
[icon]="file.showDetails ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
|
||||
(click)="file.showDetails = !file.showDetails"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@if (file.showDetails) {
|
||||
<app-bookdrop-file-metadata-picker-component
|
||||
class="px-12 py-8 custom-border1"
|
||||
style="transition: opacity 0.3s ease"
|
||||
#metadataPicker
|
||||
[originalMetadata]="file.file.originalMetadata"
|
||||
[fetchedMetadata]="file.file.fetchedMetadata!"
|
||||
[metadataForm]="file.metadataForm"
|
||||
[copiedFields]="file.copiedFields"
|
||||
[savedFields]="file.savedFields"
|
||||
[bookdropFileId]="file.file.id"
|
||||
(metadataCopied)="onMetadataCopied(file.file.id, $event)">
|
||||
</app-bookdrop-file-metadata-picker-component>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p-divider></p-divider>
|
||||
<div class="pb-4 px-6 gap-4 flex justify-end">
|
||||
@if (bookdropFileUis.length !== 0) {
|
||||
<p-button
|
||||
label="Delete Files"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
pTooltip="Permanently delete all Bookdrop files and discard any changes"
|
||||
tooltipPosition="top"
|
||||
(click)="confirmDelete()">
|
||||
</p-button>
|
||||
}
|
||||
|
||||
<p-button
|
||||
[label]="saving ? 'Finalize Imports...' : 'Finalize Imports'"
|
||||
[icon]="saving ? 'pi pi-spin pi-spinner' : 'pi pi-save'"
|
||||
severity="success"
|
||||
outlined
|
||||
pTooltip="This will move all staged files into the selected library and subpath."
|
||||
(click)="confirmFinalize()"
|
||||
[disabled]="!canFinalize || saving">
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, boolean>;
|
||||
savedFields: Record<string, boolean>;
|
||||
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<BookdropFileMetadataPickerComponent>;
|
||||
|
||||
uploadPattern = '';
|
||||
defaultLibraryId: string | null = null;
|
||||
defaultPathId: string | null = null;
|
||||
bookdropFileUis: BookdropFileUI[] = [];
|
||||
libraries: Library[] = [];
|
||||
copiedFlags: Record<number, boolean> = {};
|
||||
loading = true;
|
||||
saving = false;
|
||||
appSettings$: Observable<AppSettings | null> = 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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
64
booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts
Normal file
64
booklore-ui/src/app/bookdrop/bookdrop-file-task.service.ts
Normal file
@@ -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<BookdropFile[]> {
|
||||
return this.http.get<BookdropFile[]>(`${this.url}/files?status=pending`);
|
||||
}
|
||||
|
||||
finalizeImport(payload: BookdropFinalizePayload): Observable<BookdropFinalizeResult> {
|
||||
return this.http.post<BookdropFinalizeResult>(`${this.url}/imports/finalize`, payload);
|
||||
}
|
||||
|
||||
discardAllFile(): Observable<void> {
|
||||
return this.http.delete<void>(`${this.url}/files`);
|
||||
}
|
||||
}
|
||||
54
booklore-ui/src/app/bookdrop/bookdrop-file.service.ts
Normal file
54
booklore-ui/src/app/bookdrop/bookdrop-file.service.ts
Normal file
@@ -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<BookdropFileNotification>({
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="staging-border p-4 mt-6 rounded">
|
||||
<div class="flex justify-between items-center">
|
||||
|
||||
<div class="flex flex-col justify-center">
|
||||
<p class="text-sm font-medium text-gray-200">Pending Bookdrop Files</p>
|
||||
<p class="text-lg font-bold text-primary">{{ pendingCount }}</p>
|
||||
@if (lastUpdatedAt) {
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Last updated: {{ lastUpdatedAt | date: 'short' }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="p-button p-button-sm p-button-outlined p-button-info"
|
||||
(click)="openReviewDialog()">
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
.staging-border {
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -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<void>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<ul class="space-y-3">
|
||||
@for (result of results; track result) {
|
||||
<li
|
||||
class="flex items-start gap-3 p-3 rounded-xl border shadow-sm transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-green-800/10 border-green-500/40 text-green-100': result.success,
|
||||
'bg-red-800/10 border-red-500/40 text-red-100': !result.success
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="pi text-xl mt-1"
|
||||
[ngClass]="{
|
||||
'pi-check-circle text-green-500': result.success,
|
||||
'pi-times-circle text-red-500': !result.success
|
||||
}"
|
||||
></i>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm text-white">
|
||||
{{ result.fileName }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-400">{{ result.message }}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="flex flex-col px-4 py-6 live-border">
|
||||
<p>{{ latestNotification.timestamp }}</p>
|
||||
<p class="text-sm text-gray-400">{{ latestNotification.timestamp }}</p>
|
||||
<p>{{ latestNotification.message }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<div class="metadata-progress-box flex flex-col w-[25rem] max-h-[60vh] overflow-y-auto">
|
||||
<app-live-notification-box/>
|
||||
|
||||
@if (hasMetadataTasks$ | async) {
|
||||
<app-metadata-progress-widget/>
|
||||
}
|
||||
@if (hasPendingBookdropFiles$ | async) {
|
||||
<app-bookdrop-files-widget-component/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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$;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<number, Book> = {};
|
||||
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<FetchedProposalDto>): void {
|
||||
onSave(updatedFields: Partial<FetchedProposal>): void {
|
||||
const currentProposal = this.currentProposal;
|
||||
if (!currentProposal) return;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
<div class="w-full h-[calc(100vh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
|
||||
|
||||
<div class="p-4 pt-6 min-w-[50rem]">
|
||||
<p class="text-lg pb-4 pt-2">Auto-Download Metadata for Files in BookDrop Folder:</p>
|
||||
|
||||
<div class="flex flex-col gap-4 pl-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<p-toggleswitch
|
||||
[(ngModel)]="metadataDownloadOnBookdrop"
|
||||
(onChange)="onMetadataDownloadOnBookdropToggle($event.checked)">
|
||||
</p-toggleswitch>
|
||||
<div class="text-sm text-gray-400 flex items-start gap-2">
|
||||
<i class="pi pi-exclamation-triangle text-yellow-500 mt-0.5"></i>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-4">
|
||||
<p-divider></p-divider>
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-6 min-w-[50rem]">
|
||||
<p class="text-lg pb-4 pt-2">Metadata Persistence:</p>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user