Implement Bookdrop: Watch folder for file drops and auto-process uploads

This commit is contained in:
aditya.chandel
2025-07-18 18:36:52 -06:00
committed by Aditya Chandel
parent 5064706b8f
commit 177528e640
80 changed files with 3171 additions and 136 deletions

View File

@@ -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.)

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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);
}
}

View 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;

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
}
}
}

View File

@@ -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) {
}

View File

@@ -7,4 +7,6 @@ import java.io.File;
public interface FileMetadataExtractor {
BookMetadata extractMetadata(File file);
byte[] extractCover(File file);
}

View 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());

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +1,6 @@
app:
path-config: '/app/data'
bookdrop-folder: '/bookdrop'
version: 'v0.0.40'
swagger:
enabled: ${SWAGGER_ENABLED:false}

View File

@@ -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))
);

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
});
}
}

View File

@@ -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]}
]
},
{

View File

@@ -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';

View File

@@ -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',

View File

@@ -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({

View 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`);
}
}

View File

@@ -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>
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 hasnt 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>

View File

@@ -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;
}

View File

@@ -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: {}
};
}
}

View 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`);
}
}

View 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();
}
}

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
.staging-border {
background: var(--card-background);
border: 1px solid var(--primary-color);
border-radius: 0.5rem;
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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$;
}

View File

@@ -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'
}

View File

@@ -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
);
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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`;
}
}