Fix issue where metadata updates to file caused books to be removed from booklore

This commit is contained in:
aditya.chandel
2025-09-02 12:32:45 -06:00
committed by Aditya Chandel
parent 47e24e2241
commit 63dc2bcbc7
27 changed files with 464 additions and 169 deletions
@@ -39,7 +39,6 @@ public class AuthenticationService {
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final OpdsUserRepository opdsUserRepository;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -21,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/books")
@@ -68,12 +69,20 @@ public class MetadataController {
return ResponseEntity.noContent().build();
}
@PostMapping("/{bookId}/metadata/cover")
@PostMapping("/{bookId}/metadata/cover/upload")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<BookMetadata> uploadCover(@PathVariable Long bookId, @RequestParam("file") MultipartFile file) {
BookMetadata updatedMetadata = bookMetadataService.handleCoverUpload(bookId, file);
return ResponseEntity.ok(updatedMetadata);
public ResponseEntity<BookMetadata> uploadCoverFromFile(@PathVariable Long bookId, @RequestParam("file") MultipartFile file) {
BookMetadata updated = bookMetadataService.updateCoverImageFromFile(bookId, file);
return ResponseEntity.ok(updated);
}
@PostMapping("/{bookId}/metadata/cover/from-url")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<BookMetadata> uploadCoverFromUrl(@PathVariable Long bookId, @RequestBody Map<String, String> body) {
BookMetadata updated = bookMetadataService.updateCoverImageFromUrl(bookId, body.get("url"));
return ResponseEntity.ok(updated);
}
@PutMapping("/metadata/toggle-all-lock")
@@ -4,6 +4,7 @@ import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@@ -12,4 +13,6 @@ public interface UserRepository extends JpaRepository<BookLoreUserEntity, Long>
Optional<BookLoreUserEntity> findByUsername(String username);
Optional<BookLoreUserEntity> findById(Long id);
List<BookLoreUserEntity> findAllByLibraries_Id(Long libraryId);
}
@@ -95,33 +95,33 @@ public class SettingPersistenceHelper {
MetadataRefreshOptions getDefaultMetadataRefreshOptions() {
MetadataRefreshOptions.FieldProvider titleProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider subtitleProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider descriptionProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider authorsProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider publisherProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider publishedDateProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesNameProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesNumberProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesTotalProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider isbn13Providers =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider isbn10Providers =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider languageProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider categoriesProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider coverProviders =
new MetadataRefreshOptions.FieldProvider(MetadataProvider.Douban, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProviders,
@@ -144,7 +144,7 @@ public class SettingPersistenceHelper {
MetadataProvider.GoodReads,
MetadataProvider.Amazon,
MetadataProvider.Google,
MetadataProvider.Douban,
null,
false,
true,
false,
@@ -0,0 +1,30 @@
package com.adityachandel.booklore.service.event;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.user.UserService;
import lombok.AllArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@AllArgsConstructor
@Service
public class AdminEventBroadcaster {
private final SimpMessagingTemplate messagingTemplate;
private final UserService userService;
public void broadcastAdminEvent(String message) {
List<BookLoreUser> admins = userService.getBookLoreUsers().stream()
.filter(u -> u.getPermissions().isAdmin())
.toList();
for (BookLoreUser admin : admins) {
messagingTemplate.convertAndSendToUser(admin.getUsername(), Topic.LOG.getPath(), createLogNotification(message));
}
}
}
@@ -0,0 +1,33 @@
package com.adityachandel.booklore.service.event;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.user.UserService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@Slf4j
@AllArgsConstructor
@Service
public class BookEventBroadcaster {
private final SimpMessagingTemplate messagingTemplate;
private final UserService userService;
public void broadcastBookAddEvent(Book book) {
Long libraryId = book.getLibraryId();
userService.getBookLoreUsers().stream()
.filter(u -> u.getPermissions().isAdmin() || u.getAssignedLibraries().stream()
.anyMatch(lib -> lib.getId().equals(libraryId)))
.forEach(u -> {
String username = u.getUsername();
messagingTemplate.convertAndSendToUser(username, Topic.BOOK_ADD.getPath(), book);
messagingTemplate.convertAndSendToUser(username, Topic.LOG.getPath(), createLogNotification("Book added: " + book.getFileName()));
log.debug("Sent BOOK_ADD and LOG notifications for '{}' to user '{}'", book.getFileName(), username);
});
}
}
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.enums.BookFileExtension;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import lombok.AllArgsConstructor;
@@ -25,7 +26,7 @@ import com.adityachandel.booklore.model.enums.LibraryScanMode;
@Slf4j
public class FileAsBookProcessor implements LibraryFileProcessor {
private final NotificationService notificationService;
private final BookEventBroadcaster bookEventBroadcaster;
private final BookFileProcessorRegistry processorRegistry;
@Override
@@ -40,8 +41,7 @@ public class FileAsBookProcessor implements LibraryFileProcessor {
log.info("Processing file: {}", libraryFile.getFileName());
Book book = processLibraryFile(libraryFile);
if (book != null) {
notificationService.sendMessage(Topic.BOOK_ADD, book);
notificationService.sendMessage(Topic.LOG, createLogNotification("Book added: " + book.getFileName()));
bookEventBroadcaster.broadcastBookAddEvent(book);
log.info("Processed file: {}", libraryFile.getFileName());
}
}
@@ -13,7 +13,8 @@ import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.FileFingerprint;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.AdminEventBroadcaster;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.util.FileUtils;
@@ -35,7 +36,8 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor {
private final BookRepository bookRepository;
private final BookAdditionalFileRepository bookAdditionalFileRepository;
private final NotificationService notificationService;
private final BookEventBroadcaster bookEventBroadcaster;
private final AdminEventBroadcaster adminEventBroadcaster;
private final BookFileProcessorRegistry bookFileProcessorRegistry;
@Override
@@ -142,9 +144,9 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor {
Optional<BookEntity> parentBook =
bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(
directoryLibraryPathEntity.getId(), parentPath).stream()
.filter(book -> book.getFileSubPath().equals(parentPath))
.findFirst();
directoryLibraryPathEntity.getId(), parentPath).stream()
.filter(book -> book.getFileSubPath().equals(parentPath))
.findFirst();
if (parentBook.isPresent()) {
return parentBook;
}
@@ -173,17 +175,7 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor {
Book book = processor.processFile(bookFile);
if (book != null) {
// Send notifications
notificationService.sendMessage(
com.adityachandel.booklore.model.websocket.Topic.BOOK_ADD,
book
);
notificationService.sendMessage(
com.adityachandel.booklore.model.websocket.Topic.LOG,
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
"Book added: " + book.getFileName()
)
);
bookEventBroadcaster.broadcastBookAddEvent(book);
// Find the created book entity
BookEntity bookEntity = bookRepository.getReferenceById(book.getId());
@@ -197,26 +189,12 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor {
return Optional.of(new CreateBookResult(bookEntity, bookFile));
} else {
log.warn("Book processor returned null for file: {}", bookFile.getFileName());
notificationService.sendMessage(
com.adityachandel.booklore.model.websocket.Topic.LOG,
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
"Failed to create book from file: " + bookFile.getFileName()
)
);
adminEventBroadcaster.broadcastAdminEvent("Failed to create book from file: " + bookFile.getFileName());
return Optional.empty();
}
} catch (Exception e) {
log.error("Error processing book file {}: {}", bookFile.getFileName(), e.getMessage(), e);
notificationService.sendMessage(
com.adityachandel.booklore.model.websocket.Topic.LOG,
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
"Error processing book file: " + bookFile.getFileName() + " - " + e.getMessage()
)
);
adminEventBroadcaster.broadcastAdminEvent("Error processing book file: " + bookFile.getFileName() + " - " + e.getMessage());
return Optional.empty();
}
}
@@ -280,8 +258,10 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor {
}
}
public record GetOrCreateBookResult(Optional<BookEntity> bookEntity, List<LibraryFile> remainingFiles) {}
public record GetOrCreateBookResult(Optional<BookEntity> bookEntity, List<LibraryFile> remainingFiles) {
}
public record CreateBookResult(BookEntity bookEntity, LibraryFile libraryFile) {}
public record CreateBookResult(BookEntity bookEntity, LibraryFile libraryFile) {
}
}
@@ -20,6 +20,7 @@ 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.metadata.writer.MetadataWriter;
import com.adityachandel.booklore.util.SecurityContextVirtualThread;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
@@ -44,6 +45,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@@ -148,14 +150,23 @@ public class BookMetadataService {
}
@Transactional
public BookMetadata handleCoverUpload(Long bookId, MultipartFile file) {
public BookMetadata updateCoverImageFromFile(Long bookId, MultipartFile file) {
fileService.createThumbnailFromFile(bookId, file);
return updateCover(bookId, (writer, book) -> writer.replaceCoverImageFromUpload(book, file));
}
@Transactional
public BookMetadata updateCoverImageFromUrl(Long bookId, String url) {
fileService.createThumbnailFromUrl(bookId, url);
return updateCover(bookId, (writer, book) -> writer.replaceCoverImageFromUrl(book, url));
}
private BookMetadata updateCover(Long bookId, BiConsumer<MetadataWriter, BookEntity> writerAction) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
boolean saveToOriginalFile = appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile();
if (saveToOriginalFile) {
if (appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile()) {
metadataWriterFactory.getWriter(bookEntity.getBookType())
.ifPresent(writer -> writer.replaceCoverImageFromUpload(bookEntity, file));
.ifPresent(writer -> writerAction.accept(writer, bookEntity));
}
return bookMetadataMapper.toBookMetadata(bookEntity.getMetadata(), true);
}
@@ -261,4 +272,5 @@ public class BookMetadataService {
return null;
}
}
}
}
@@ -59,6 +59,7 @@ public class BookMetadataUpdater {
boolean thumbnailRequiresUpdate = StringUtils.hasText(newMetadata.getThumbnailUrl());
boolean hasMetadataChanges = MetadataChangeDetector.isDifferent(newMetadata, metadata, clearFlags);
boolean hasValueChanges = MetadataChangeDetector.hasValueChanges(newMetadata, metadata, clearFlags);
if (!thumbnailRequiresUpdate && !hasMetadataChanges) {
log.info("No changes in metadata for book ID {}. Skipping update.", bookId);
return;
@@ -102,13 +103,13 @@ public class BookMetadataUpdater {
log.warn("Failed to calculate metadata match score for book ID {}: {}", bookId, e.getMessage());
}
if (writeToFile) {
if ((writeToFile && hasValueChanges) || thumbnailRequiresUpdate) {
metadataWriterFactory.getWriter(bookType).ifPresent(writer -> {
try {
String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null;
if (StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl)) {
log.warn("Blocked local/private thumbnail URL: {}", thumbnailUrl);
if ((StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl) || Boolean.TRUE.equals(metadata.getCoverLocked()))) {
log.debug("Blocked local/private thumbnail URL: {}", thumbnailUrl);
thumbnailUrl = null;
}
@@ -116,8 +117,6 @@ public class BookMetadataUpdater {
writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags);
String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath());
bookEntity.setCurrentHash(newHash);
log.info("Metadata written for book ID {}", bookId);
} catch (Exception e) {
log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage());
}
@@ -88,7 +88,7 @@ public class GoogleParser implements BookParser {
.build()
.toUri();
log.info("Google Books API URLx: {}", uri);
log.info("Google Books API URL: {}", uri);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
@@ -31,7 +31,7 @@ import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -44,6 +44,13 @@ public class EpubMetadataWriter implements MetadataWriter {
@Override
public void writeMetadataToFile(File epubFile, BookMetadataEntity metadata, String thumbnailUrl, boolean restoreMode, MetadataClearFlags clear) {
File backupFile = new File(epubFile.getParentFile(), epubFile.getName() + ".bak");
try {
Files.copy(epubFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
log.warn("Failed to create backup of EPUB {}: {}", epubFile.getName(), ex.getMessage());
return;
}
Path tempDir;
try {
tempDir = Files.createTempDirectory("epub_edit_" + UUID.randomUUID());
@@ -155,7 +162,7 @@ public class EpubMetadataWriter implements MetadataWriter {
hasChanges[0] = true;
}
}
if (hasChanges[0]) {
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
@@ -172,9 +179,24 @@ public class EpubMetadataWriter implements MetadataWriter {
} else {
log.info("No changes detected. Skipping EPUB write for: {}", epubFile.getName());
}
} catch (Exception e) {
log.warn("Failed to write metadata to EPUB file {}: {}", epubFile.getName(), e.getMessage(), e);
if (backupFile.exists()) {
try {
Files.copy(backupFile.toPath(), epubFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
log.info("Restored EPUB from backup: {}", epubFile.getName());
} catch (IOException io) {
log.error("Failed to restore EPUB from backup for {}: {}", epubFile.getName(), io.getMessage(), io);
}
}
} finally {
if (backupFile.exists()) {
try {
Files.delete(backupFile.toPath());
} catch (IOException ex) {
log.warn("Failed to delete backup for {}: {}", epubFile.getName(), ex.getMessage());
}
}
}
}
@@ -267,6 +289,53 @@ public class EpubMetadataWriter implements MetadataWriter {
}
}
@Override
public void replaceCoverImageFromUrl(BookEntity bookEntity, String url) {
if (url == null || url.isBlank()) {
log.warn("Cover update via URL failed: empty or null URL.");
return;
}
try {
File epubFile = new File(bookEntity.getFullFilePath().toUri());
Path tempDir = Files.createTempDirectory("epub_cover_url_" + UUID.randomUUID());
new ZipFile(epubFile).extractAll(tempDir.toString());
File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
log.warn("OPF file not found in EPUB: {}", epubFile.getName());
return;
}
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document opfDoc = builder.parse(opfFile);
byte[] coverData = loadImage(url);
if (coverData == null) {
log.warn("Failed to load image from URL: {}", url);
return;
}
applyCoverImageToEpub(tempDir, opfDoc, coverData);
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
addFolderContentsToZip(new ZipFile(tempEpub), tempDir.toFile(), tempDir.toFile());
if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");
log.info("Cover image updated in EPUB via URL: {}", epubFile.getName());
} catch (Exception e) {
log.warn("Failed to update EPUB with cover from URL: {}", e.getMessage(), e);
}
}
@Override
public BookFileType getSupportedBookType() {
return BookFileType.EPUB;
@@ -466,4 +535,5 @@ public class EpubMetadataWriter implements MetadataWriter {
}
return null;
}
}
}
@@ -15,5 +15,8 @@ public interface MetadataWriter {
default void replaceCoverImageFromUpload(BookEntity bookEntity, MultipartFile file) {
}
default void replaceCoverImageFromUrl(BookEntity bookEntity, String url) {
}
BookFileType getSupportedBookType();
}
@@ -28,6 +28,7 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@@ -35,6 +36,7 @@ import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@Slf4j
@Component
@@ -47,21 +49,48 @@ public class PdfMetadataWriter implements MetadataWriter {
return;
}
Path filePath = file.toPath();
Path backupPath = null;
boolean backupCreated = false;
File tempFile = null;
try {
String prefix = "pdfBackup-" + UUID.randomUUID() + "-";
backupPath = Files.createTempFile(prefix, ".pdf");
Files.copy(filePath, backupPath, StandardCopyOption.REPLACE_EXISTING);
backupCreated = true;
} catch (IOException e) {
log.warn("Could not create PDF temp backup for {}: {}", file.getName(), e.getMessage());
}
try (PDDocument pdf = Loader.loadPDF(file)) {
pdf.setAllSecurityToBeRemoved(true);
applyMetadataToDocument(pdf, metadataEntity, restoreMode, clear);
tempFile = File.createTempFile("pdfmeta-", ".pdf");
pdf.save(tempFile);
Files.move(tempFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.move(tempFile.toPath(), filePath, StandardCopyOption.REPLACE_EXISTING);
log.info("Successfully embedded metadata into PDF: {}", file.getName());
} catch (IOException e) {
log.warn("Failed to write metadata to PDF {}", file.getName(), e);
} catch (Exception e) {
log.warn("Failed to write metadata to PDF {}: {}", file.getName(), e.getMessage(), e);
if (backupCreated) {
try {
Files.copy(backupPath, filePath, StandardCopyOption.REPLACE_EXISTING);
log.info("Restored PDF {} from temp backup after failure", file.getName());
} catch (IOException ex) {
log.error("Failed to restore PDF temp backup for {}: {}", file.getName(), ex.getMessage(), ex);
}
}
} finally {
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
if (backupCreated) {
try {
Files.deleteIfExists(backupPath);
} catch (IOException e) {
log.warn("Could not delete PDF temp backup for {}: {}", file.getName(), e.getMessage());
}
}
}
}
@@ -164,6 +164,13 @@ public class UserService {
userRepository.save(user);
}
public List<BookLoreUser> getUsersWithLibraryAccess(Long libraryId) {
return userRepository.findAllByLibraries_Id(libraryId)
.stream()
.map(bookLoreUserTransformer::toDTO)
.collect(Collectors.toList());
}
private boolean meetsMinimumPasswordRequirements(String password) {
return password != null && password.length() >= 6;
}
@@ -43,12 +43,12 @@ public class BookFilePersistenceService {
boolean pathChanged = !Objects.equals(newSubPath, book.getFileSubPath()) || !Objects.equals(newLibraryPath.getId(), book.getLibraryPath().getId());
if (pathChanged) {
if (pathChanged || Boolean.TRUE.equals(book.getDeleted())) {
book.setLibraryPath(newLibraryPath);
book.setFileSubPath(newSubPath);
book.setDeleted(false);
book.setDeleted(Boolean.FALSE);
bookRepository.save(book);
log.info("[FILE_CREATE] Updated path for existing book with hash '{}': '{}'", currentHash, path);
log.info("[FILE_CREATE] Updated path / undeleted existing book with hash '{}': '{}'", currentHash, path);
} else {
log.info("[FILE_CREATE] Book with hash '{}' already exists at same path. Skipping update.", currentHash);
}
@@ -1,7 +1,6 @@
package com.adityachandel.booklore.service.watcher;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileExtension;
@@ -19,7 +18,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.*;
@@ -29,13 +28,17 @@ import java.util.concurrent.*;
@AllArgsConstructor
public class LibraryFileEventProcessor {
private static final long DEBOUNCE_MS = 500L;
private final BlockingQueue<FileEvent> eventQueue = new LinkedBlockingQueue<>();
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private final LibraryRepository libraryRepository;
private final BookFileTransactionalHandler bookFileTransactionalHandler;
private final BookFilePersistenceService bookFilePersistenceService;
private final NotificationService notificationService;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final ConcurrentMap<Path, ScheduledFuture<?>> pendingDeletes = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
Thread.ofVirtual().start(() -> {
@@ -54,17 +57,41 @@ public class LibraryFileEventProcessor {
}
public void processFile(WatchEvent.Kind<?> eventKind, long libraryId, String libraryPath, String filePath) {
eventQueue.offer(new FileEvent(eventKind, libraryId, libraryPath, filePath));
Path path = Paths.get(filePath).toAbsolutePath().normalize();
if (eventKind == StandardWatchEventKinds.ENTRY_DELETE) {
// Schedule DELETE after debounce
ScheduledFuture<?> existing = pendingDeletes.put(path, scheduler.schedule(() -> {
eventQueue.offer(new FileEvent(eventKind, libraryId, libraryPath, filePath));
pendingDeletes.remove(path);
}, DEBOUNCE_MS, TimeUnit.MILLISECONDS));
if (existing != null) existing.cancel(false);
} else if (eventKind == StandardWatchEventKinds.ENTRY_CREATE) {
// If a DELETE is pending for this path, cancel both DELETE and CREATE
ScheduledFuture<?> pendingDelete = pendingDeletes.remove(path);
if (pendingDelete != null) {
pendingDelete.cancel(false);
log.info("[DEBOUNCE] CREATE ignored because pending DELETE exists for '{}'", path);
return;
}
// Otherwise process CREATE immediately
eventQueue.offer(new FileEvent(eventKind, libraryId, libraryPath, filePath));
} else {
// Other events
eventQueue.offer(new FileEvent(eventKind, libraryId, libraryPath, filePath));
}
}
private void handleEvent(FileEvent event) {
Path path = Paths.get(event.filePath());
Path path = Paths.get(event.filePath()).toAbsolutePath().normalize();
String fileName = path.getFileName().toString();
log.info("[PROCESS] '{}' event for '{}'", event.eventKind().name(), fileName);
LibraryEntity library = libraryRepository.findById(event.libraryId()).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(event.libraryId()));
LibraryEntity library = libraryRepository.findById(event.libraryId())
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(event.libraryId()));
if (library.getLibraryPaths().stream().noneMatch(lp -> path.toString().startsWith(lp.getPath()))) {
if (library.getLibraryPaths().stream().noneMatch(lp -> path.startsWith(lp.getPath()))) {
log.warn("[SKIP] Path outside of library: '{}'", path);
return;
}
@@ -102,7 +129,7 @@ public class LibraryFileEventProcessor {
String libPath = bookFilePersistenceService.findMatchingLibraryPath(library, path);
LibraryPathEntity libPathEntity = bookFilePersistenceService.getLibraryPathEntityForFile(library, libPath);
Path relPath = Paths.get(libPathEntity.getPath()).relativize(path.toAbsolutePath().normalize());
Path relPath = Paths.get(libPathEntity.getPath()).relativize(path);
String fileName = relPath.getFileName().toString();
String fileSubPath = Optional.ofNullable(relPath.getParent()).map(Path::toString).orElse("");
@@ -110,7 +137,8 @@ public class LibraryFileEventProcessor {
.ifPresentOrElse(book -> {
book.setDeleted(true);
bookFilePersistenceService.save(book);
notificationService.sendMessageToPermissions(Topic.BOOKS_REMOVE, Set.of(book.getId()), Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY));
notificationService.sendMessageToPermissions(Topic.BOOKS_REMOVE, Set.of(book.getId()),
Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY));
log.info("[MARKED_DELETED] Book '{}' marked as deleted", fileName);
}, () -> log.warn("[NOT_FOUND] Book for deleted path '{}' not found", path));
@@ -161,8 +189,8 @@ public class LibraryFileEventProcessor {
@PreDestroy
public void shutdown() {
worker.shutdownNow();
log.info("LibraryFileEventProcessor worker shutdown.");
scheduler.shutdownNow();
log.info("Shutting down LibraryFileEventProcessor...");
}
public record FileEvent(WatchEvent.Kind<?> eventKind, long libraryId, String libraryPath, String filePath) {
@@ -2,15 +2,17 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.MetadataClearFlags;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
@Slf4j
public class MetadataChangeDetector {
@@ -60,6 +62,69 @@ public class MetadataChangeDetector {
return false;
}
public static boolean hasValueChanges(BookMetadata newMeta, BookMetadataEntity existingMeta, MetadataClearFlags clear) {
List<String> diffs = new ArrayList<>();
compareValue(diffs, "title", clear.isTitle(), newMeta.getTitle(), existingMeta.getTitle(), () -> !isTrue(existingMeta.getTitleLocked()));
compareValue(diffs, "subtitle", clear.isSubtitle(), newMeta.getSubtitle(), existingMeta.getSubtitle(), () -> !isTrue(existingMeta.getSubtitleLocked()));
compareValue(diffs, "publisher", clear.isPublisher(), newMeta.getPublisher(), existingMeta.getPublisher(), () -> !isTrue(existingMeta.getPublisherLocked()));
compareValue(diffs, "publishedDate", clear.isPublishedDate(), newMeta.getPublishedDate(), existingMeta.getPublishedDate(), () -> !isTrue(existingMeta.getPublishedDateLocked()));
compareValue(diffs, "description", clear.isDescription(), newMeta.getDescription(), existingMeta.getDescription(), () -> !isTrue(existingMeta.getDescriptionLocked()));
compareValue(diffs, "seriesName", clear.isSeriesName(), newMeta.getSeriesName(), existingMeta.getSeriesName(), () -> !isTrue(existingMeta.getSeriesNameLocked()));
compareValue(diffs, "seriesNumber", clear.isSeriesNumber(), newMeta.getSeriesNumber(), existingMeta.getSeriesNumber(), () -> !isTrue(existingMeta.getSeriesNumberLocked()));
compareValue(diffs, "seriesTotal", clear.isSeriesTotal(), newMeta.getSeriesTotal(), existingMeta.getSeriesTotal(), () -> !isTrue(existingMeta.getSeriesTotalLocked()));
compareValue(diffs, "isbn13", clear.isIsbn13(), newMeta.getIsbn13(), existingMeta.getIsbn13(), () -> !isTrue(existingMeta.getIsbn13Locked()));
compareValue(diffs, "isbn10", clear.isIsbn10(), newMeta.getIsbn10(), existingMeta.getIsbn10(), () -> !isTrue(existingMeta.getIsbn10Locked()));
compareValue(diffs, "asin", clear.isAsin(), newMeta.getAsin(), existingMeta.getAsin(), () -> !isTrue(existingMeta.getAsinLocked()));
compareValue(diffs, "goodreadsId", clear.isGoodreadsId(), newMeta.getGoodreadsId(), existingMeta.getGoodreadsId(), () -> !isTrue(existingMeta.getGoodreadsIdLocked()));
compareValue(diffs, "comicvineId", clear.isComicvineId(), newMeta.getComicvineId(), existingMeta.getComicvineId(), () -> !isTrue(existingMeta.getComicvineIdLocked()));
compareValue(diffs, "hardcoverId", clear.isHardcoverId(), newMeta.getHardcoverId(), existingMeta.getHardcoverId(), () -> !isTrue(existingMeta.getHardcoverIdLocked()));
compareValue(diffs, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked()));
compareValue(diffs, "pageCount", clear.isPageCount(), newMeta.getPageCount(), existingMeta.getPageCount(), () -> !isTrue(existingMeta.getPageCountLocked()));
compareValue(diffs, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked()));
compareValue(diffs, "personalRating", clear.isPersonalRating(), newMeta.getPersonalRating(), existingMeta.getPersonalRating(), () -> !isTrue(existingMeta.getPersonalRatingLocked()));
compareValue(diffs, "amazonRating", clear.isAmazonRating(), newMeta.getAmazonRating(), existingMeta.getAmazonRating(), () -> !isTrue(existingMeta.getAmazonRatingLocked()));
compareValue(diffs, "amazonReviewCount", clear.isAmazonReviewCount(), newMeta.getAmazonReviewCount(), existingMeta.getAmazonReviewCount(), () -> !isTrue(existingMeta.getAmazonReviewCountLocked()));
compareValue(diffs, "goodreadsRating", clear.isGoodreadsRating(), newMeta.getGoodreadsRating(), existingMeta.getGoodreadsRating(), () -> !isTrue(existingMeta.getGoodreadsRatingLocked()));
compareValue(diffs, "goodreadsReviewCount", clear.isGoodreadsReviewCount(), newMeta.getGoodreadsReviewCount(), existingMeta.getGoodreadsReviewCount(), () -> !isTrue(existingMeta.getGoodreadsReviewCountLocked()));
compareValue(diffs, "hardcoverRating", clear.isHardcoverRating(), newMeta.getHardcoverRating(), existingMeta.getHardcoverRating(), () -> !isTrue(existingMeta.getHardcoverRatingLocked()));
compareValue(diffs, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()));
compareValue(diffs, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()));
compareValue(diffs, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()));
return !diffs.isEmpty();
}
public static boolean hasLockChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
if (differsLock(newMeta.getTitleLocked(), existingMeta.getTitleLocked())) return true;
if (differsLock(newMeta.getSubtitleLocked(), existingMeta.getSubtitleLocked())) return true;
if (differsLock(newMeta.getPublisherLocked(), existingMeta.getPublisherLocked())) return true;
if (differsLock(newMeta.getPublishedDateLocked(), existingMeta.getPublishedDateLocked())) return true;
if (differsLock(newMeta.getDescriptionLocked(), existingMeta.getDescriptionLocked())) return true;
if (differsLock(newMeta.getSeriesNameLocked(), existingMeta.getSeriesNameLocked())) return true;
if (differsLock(newMeta.getSeriesNumberLocked(), existingMeta.getSeriesNumberLocked())) return true;
if (differsLock(newMeta.getSeriesTotalLocked(), existingMeta.getSeriesTotalLocked())) return true;
if (differsLock(newMeta.getIsbn13Locked(), existingMeta.getIsbn13Locked())) return true;
if (differsLock(newMeta.getIsbn10Locked(), existingMeta.getIsbn10Locked())) return true;
if (differsLock(newMeta.getAsinLocked(), existingMeta.getAsinLocked())) return true;
if (differsLock(newMeta.getGoodreadsIdLocked(), existingMeta.getGoodreadsIdLocked())) return true;
if (differsLock(newMeta.getComicvineIdLocked(), existingMeta.getComicvineIdLocked())) return true;
if (differsLock(newMeta.getHardcoverIdLocked(), existingMeta.getHardcoverIdLocked())) return true;
if (differsLock(newMeta.getGoogleIdLocked(), existingMeta.getGoogleIdLocked())) return true;
if (differsLock(newMeta.getPageCountLocked(), existingMeta.getPageCountLocked())) return true;
if (differsLock(newMeta.getLanguageLocked(), existingMeta.getLanguageLocked())) return true;
if (differsLock(newMeta.getPersonalRatingLocked(), existingMeta.getPersonalRatingLocked())) return true;
if (differsLock(newMeta.getAmazonRatingLocked(), existingMeta.getAmazonRatingLocked())) return true;
if (differsLock(newMeta.getAmazonReviewCountLocked(), existingMeta.getAmazonReviewCountLocked())) return true;
if (differsLock(newMeta.getGoodreadsRatingLocked(), existingMeta.getGoodreadsRatingLocked())) return true;
if (differsLock(newMeta.getGoodreadsReviewCountLocked(), existingMeta.getGoodreadsReviewCountLocked())) return true;
if (differsLock(newMeta.getHardcoverRatingLocked(), existingMeta.getHardcoverRatingLocked())) return true;
if (differsLock(newMeta.getHardcoverReviewCountLocked(), existingMeta.getHardcoverReviewCountLocked())) return true;
if (differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked())) return true;
if (differsLock(newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked())) return true;
if (differsLock(newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked())) return true;
if (differsLock(newMeta.getReviewsLocked(), existingMeta.getReviewsLocked())) return true;
return false;
}
private static void compare(List<String> diffs, String field, boolean shouldClear, Object newVal, Object oldVal, Supplier<Boolean> isUnlocked, Boolean newLock, Boolean oldLock) {
boolean valueChanged = differs(shouldClear, newVal, oldVal, isUnlocked);
boolean lockChanged = differsLock(newLock, oldLock);
@@ -73,6 +138,17 @@ public class MetadataChangeDetector {
}
}
private static <T> void compareValue(List<String> diffs,
String field,
boolean shouldClear,
T newVal,
T oldVal,
Supplier<Boolean> isUnlocked) {
if (differs(shouldClear, newVal, oldVal, isUnlocked)) {
diffs.add(field + " changed");
}
}
private static boolean differs(boolean shouldClear, Object newVal, Object oldVal, Supplier<Boolean> isUnlocked) {
if (!isUnlocked.get()) return false;
@@ -109,21 +185,19 @@ public class MetadataChangeDetector {
}
private static Set<String> toNameSet(Set<?> entities) {
if (entities == null) return null;
if (entities == null) {
return Collections.emptySet();
}
return entities.stream()
.map(e -> {
try {
return (String) e.getClass().getMethod("getName").invoke(e);
} catch (Exception ex) {
return null;
}
})
.filter(Objects::nonNull)
.map(String::strip)
.collect(Collectors.toSet());
.map(e -> {
if (e instanceof AuthorEntity author) {
return author.getName();
}
if (e instanceof CategoryEntity category) {
return category.getName();
}
return e.toString();
})
.collect(Collectors.toSet());
}
private static boolean isTrue(Boolean value) {
return Boolean.TRUE.equals(value);
}
}
}
@@ -5,9 +5,7 @@ import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.websocket.LogNotification;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import org.junit.jupiter.api.AfterEach;
@@ -19,14 +17,13 @@ import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FileAsBookProcessorTest {
@Mock
private NotificationService notificationService;
private BookEventBroadcaster bookEventBroadcaster;
@Mock
private BookFileProcessorRegistry processorRegistry;
@@ -102,9 +99,8 @@ class FileAsBookProcessorTest {
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, times(2)).sendMessage(eq(Topic.BOOK_ADD), bookCaptor.capture());
verify(notificationService, times(2)).sendMessage(eq(Topic.LOG), any(LogNotification.class));
verify(bookEventBroadcaster, times(2)).broadcastBookAddEvent(bookCaptor.capture());
List<Book> capturedBooks = bookCaptor.getAllValues();
assertThat(capturedBooks).hasSize(2);
assertThat(capturedBooks).containsExactly(book1, book2);
@@ -150,8 +146,7 @@ class FileAsBookProcessorTest {
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, times(1)).sendMessage(eq(Topic.BOOK_ADD), eq(book));
verify(notificationService, times(1)).sendMessage(eq(Topic.LOG), any(LogNotification.class));
verify(bookEventBroadcaster, times(1)).broadcastBookAddEvent(book);
verify(processorRegistry, times(1)).getProcessorOrThrow(any());
}
@@ -165,7 +160,7 @@ class FileAsBookProcessorTest {
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, never()).sendMessage(any(Topic.class), any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(processorRegistry, never()).getProcessorOrThrow(any());
}
@@ -275,7 +270,7 @@ class FileAsBookProcessorTest {
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, never()).sendMessage(any(Topic.class), any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
}
@Test
@@ -356,7 +351,6 @@ class FileAsBookProcessorTest {
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, times(4)).sendMessage(eq(Topic.BOOK_ADD), any(Book.class));
verify(notificationService, times(4)).sendMessage(eq(Topic.LOG), any(LogNotification.class));
verify(bookEventBroadcaster, times(4)).broadcastBookAddEvent(any(Book.class));
}
}
@@ -5,7 +5,8 @@ import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.FileFingerprint;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.AdminEventBroadcaster;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.util.FileUtils;
@@ -30,7 +31,10 @@ class FolderAsBookFileProcessorExampleTest {
private BookAdditionalFileRepository bookAdditionalFileRepository;
@Mock
private NotificationService notificationService;
private BookEventBroadcaster bookEventBroadcaster;
@Mock
private AdminEventBroadcaster adminEventBroadcaster;
@Mock
private BookFileProcessorRegistry bookFileProcessorRegistry;
@@ -4,6 +4,8 @@ import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.AdminEventBroadcaster;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import org.junit.jupiter.api.Test;
@@ -16,11 +18,12 @@ class FolderAsBookFileProcessorSimpleTest {
void getScanMode_shouldReturnFolderAsBook() {
BookRepository bookRepository = mock(BookRepository.class);
BookAdditionalFileRepository bookAdditionalFileRepository = mock(BookAdditionalFileRepository.class);
NotificationService notificationService = mock(NotificationService.class);
AdminEventBroadcaster adminEventBroadcaster = mock(AdminEventBroadcaster.class);
BookEventBroadcaster bookEventBroadcaster = mock(BookEventBroadcaster.class);
BookFileProcessorRegistry bookFileProcessorRegistry = mock(BookFileProcessorRegistry.class);
FolderAsBookFileProcessor processor = new FolderAsBookFileProcessor(
bookRepository, bookAdditionalFileRepository, notificationService, bookFileProcessorRegistry);
bookRepository, bookAdditionalFileRepository, bookEventBroadcaster, adminEventBroadcaster, bookFileProcessorRegistry);
assertThat(processor.getScanMode()).isEqualTo(LibraryScanMode.FOLDER_AS_BOOK);
}
@@ -29,11 +32,12 @@ class FolderAsBookFileProcessorSimpleTest {
void supportsSupplementaryFiles_shouldReturnTrue() {
BookRepository bookRepository = mock(BookRepository.class);
BookAdditionalFileRepository bookAdditionalFileRepository = mock(BookAdditionalFileRepository.class);
NotificationService notificationService = mock(NotificationService.class);
AdminEventBroadcaster adminEventBroadcaster = mock(AdminEventBroadcaster.class);
BookEventBroadcaster bookEventBroadcaster = mock(BookEventBroadcaster.class);
BookFileProcessorRegistry bookFileProcessorRegistry = mock(BookFileProcessorRegistry.class);
FolderAsBookFileProcessor processor = new FolderAsBookFileProcessor(
bookRepository, bookAdditionalFileRepository, notificationService, bookFileProcessorRegistry);
bookRepository, bookAdditionalFileRepository, bookEventBroadcaster, adminEventBroadcaster, bookFileProcessorRegistry);
assertThat(processor.supportsSupplementaryFiles()).isTrue();
}
@@ -6,12 +6,11 @@ import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.model.enums.AdditionalFileType;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.model.websocket.LogNotification;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.event.AdminEventBroadcaster;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.FileFingerprint;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.util.FileUtils;
@@ -43,7 +42,10 @@ class FolderAsBookFileProcessorTest {
private BookAdditionalFileRepository bookAdditionalFileRepository;
@Mock
private NotificationService notificationService;
private BookEventBroadcaster bookEventBroadcaster;
@Mock
private AdminEventBroadcaster adminEventBroadcaster;
@Mock
private BookFileProcessorRegistry bookFileProcessorRegistry;
@@ -127,8 +129,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor).processFile(any(LibraryFile.class));
verify(notificationService).sendMessage(Topic.BOOK_ADD, createdBook);
verify(notificationService).sendMessage(eq(Topic.LOG), any(LogNotification.class));
verify(bookEventBroadcaster).broadcastBookAddEvent(createdBook);
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
@@ -160,7 +162,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor, never()).processFile(any());
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
@@ -192,6 +195,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor, never()).processFile(any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
@@ -236,6 +241,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor).processFile(argThat(file -> file.getFileName().equals("book.epub")));
verify(bookEventBroadcaster).broadcastBookAddEvent(createdBook);
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
@@ -279,6 +286,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor).processFile(argThat(file -> file.getFileName().equals("book.pdf")));
verify(bookEventBroadcaster).broadcastBookAddEvent(createdBook);
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
}
@Test
@@ -310,6 +319,8 @@ class FolderAsBookFileProcessorTest {
processor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(1)).save(additionalFileCaptor.capture());
BookAdditionalFileEntity capturedFile = additionalFileCaptor.getValue();
@@ -333,7 +344,8 @@ class FolderAsBookFileProcessorTest {
// Then
verify(mockBookFileProcessor, never()).processFile(any());
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, never()).save(any());
}
@@ -356,8 +368,9 @@ class FolderAsBookFileProcessorTest {
processor.processLibraryFiles(libraryFiles, libraryEntity);
// Then
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any());
verify(bookAdditionalFileRepository, never()).save(any());
verify(adminEventBroadcaster).broadcastAdminEvent(anyString());
}
// Helper methods
@@ -316,7 +316,7 @@ export class BookService {
const currentState = this.bookStateSubject.value;
const updatedBooks = (currentState.books || []).map(book => {
if (book.id === bookId) {
const updatedBook = { ...book };
const updatedBook = {...book};
if (fileType === AdditionalFileType.ALTERNATIVE_FORMAT) {
updatedBook.alternativeFormats = [...(book.alternativeFormats || []), newFile];
} else {
@@ -538,7 +538,17 @@ export class BookService {
}
getUploadCoverUrl(bookId: number): string {
return this.url + '/' + bookId + "/metadata/cover"
return this.url + '/' + bookId + "/metadata/cover/upload"
}
uploadCoverFromUrl(bookId: number, url: string): Observable<BookMetadata> {
return this.http
.post<BookMetadata>(`${this.url}/${bookId}/metadata/cover/from-url`, {url})
.pipe(
tap(updatedMetadata =>
this.handleBookMetadataUpdate(bookId, updatedMetadata)
)
);
}
getBookRecommendations(bookId: number, limit: number = 20): Observable<BookRecommendation[]> {
@@ -688,4 +698,5 @@ export class BookService {
getBackupMetadata(bookId: number) {
return this.http.get<any>(`${this.url}/${bookId}/metadata/restore`);
}
}
@@ -605,12 +605,5 @@ export class MetadataEditorComponent implements OnInit {
position: 'absolute'
},
});
ref.onClose.subscribe(result => {
if (result) {
this.metadataForm.get('thumbnailUrl')?.setValue(result);
this.onSave();
}
});
}
}
@@ -97,3 +97,4 @@
</div>
}
</div>
@@ -1,4 +1,6 @@
import {Component, inject, Input, OnInit} from '@angular/core';
import {Toast} from 'primeng/toast';
import {MessageService} from 'primeng/api';
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {BookCoverService, CoverFetchRequest, CoverImage} from '../../shared/services/book-cover.service';
import {finalize} from 'rxjs/operators';
@@ -28,14 +30,13 @@ export class CoverSearchComponent implements OnInit {
coverImages: CoverImage[] = [];
loading = false;
hasSearched = false;
showPreview = false;
previewImageUrl = '';
private fb = inject(FormBuilder);
private bookCoverService = inject(BookCoverService);
private dynamicDialogConfig = inject(DynamicDialogConfig);
protected dynamicDialogRef = inject(DynamicDialogRef);
protected bookService = inject(BookService);
private messageService = inject(MessageService);
constructor() {
this.searchForm = this.fb.group({
@@ -87,26 +88,24 @@ export class CoverSearchComponent implements OnInit {
}
selectAndSave(image: CoverImage) {
this.dynamicDialogRef.close(image.url);
}
previewImage(imageUrl: string) {
this.previewImageUrl = imageUrl;
this.showPreview = true;
document.body.style.overflow = 'hidden';
}
closePreview() {
this.showPreview = false;
this.previewImageUrl = '';
document.body.style.overflow = 'auto';
}
onImageError(event: Event) {
const target = event.target as HTMLImageElement;
if (target) {
target.style.display = 'none';
}
this.bookService.uploadCoverFromUrl(this.bookId, image.url)
.subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Cover Updated',
detail: 'Cover image updated successfully.'
});
this.dynamicDialogRef.close();
},
error: err => {
this.messageService.add({
severity: 'error',
summary: 'Cover Update Failed',
detail: err?.message || 'Failed to update cover image.'
});
}
});
}
onClear() {
@@ -32,7 +32,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
'language', 'categories', 'cover'
];
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine'];
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
refreshCovers: boolean = false;
mergeCategories: boolean = false;