diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/DuplicateFileInfo.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/DuplicateFileInfo.java new file mode 100644 index 000000000..4047d3429 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/DuplicateFileInfo.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@AllArgsConstructor +public class DuplicateFileInfo { + private final Long bookId; + private final String fileName; + private final String fullPath; + private final String hash; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/FileProcessResult.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/FileProcessResult.java new file mode 100644 index 000000000..209888705 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/FileProcessResult.java @@ -0,0 +1,19 @@ +package com.adityachandel.booklore.model; + +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.enums.FileProcessStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Builder +@Getter +@ToString +@AllArgsConstructor +public class FileProcessResult { + private final Book book; + private final FileProcessStatus status; + @Builder.Default + private final DuplicateFileInfo duplicate = null; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/DuplicateFileNotification.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/DuplicateFileNotification.java new file mode 100644 index 000000000..34464aeb3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/DuplicateFileNotification.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.*; + +import java.time.Instant; + +@NoArgsConstructor +@AllArgsConstructor +@Setter +@Getter +@Builder +public class DuplicateFileNotification { + private long libraryId; + private String libraryName; + private long fileId; + private String fileName; + private String fullPath; + private String hash; + private Instant timestamp; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/LibraryFile.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/LibraryFile.java index 52aeebb21..56ad61d24 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/LibraryFile.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/LibraryFile.java @@ -21,6 +21,9 @@ public class LibraryFile { private BookFileType bookFileType; public Path getFullPath() { + if (fileSubPath == null || fileSubPath.isEmpty()) { + return Paths.get(libraryPathEntity.getPath(), fileName); + } return Paths.get(libraryPathEntity.getPath(), fileSubPath, fileName); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/FileProcessStatus.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/FileProcessStatus.java new file mode 100644 index 000000000..98c440ea5 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/FileProcessStatus.java @@ -0,0 +1,7 @@ +package com.adityachandel.booklore.model.enums; + +public enum FileProcessStatus { + NEW, + DUPLICATE, + UPDATED +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java index 72306825d..6f889be72 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/websocket/Topic.java @@ -12,6 +12,7 @@ public enum Topic { BOOK_METADATA_BATCH_UPDATE("/queue/book-metadata-batch-update"), BOOK_METADATA_BATCH_PROGRESS("/queue/book-metadata-batch-progress"), BOOKDROP_FILE("/queue/bookdrop-file"), + DUPLICATE_FILE("/queue/duplicate-file"), TASK("/queue/task"), LOG("/queue/log"); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java index 84e0796bb..b10e02f10 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.bookdrop; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookdropFileMapper; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.BookdropFile; @@ -180,7 +181,7 @@ public class BookDropService { results.getTotalFiles()); return results; - + } finally { bookdropMonitoringService.resumeMonitoring(); } @@ -283,15 +284,15 @@ public class BookDropService { log.info("Moved file id={}, name={} from '{}' to '{}'", bookdropFile.getId(), bookdropFile.getFileName(), source, target); - Book processedBook = processFile(targetFile.getName(), library, path, targetFile, + FileProcessResult fileProcessResult = 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()) + BookEntity bookEntity = bookRepository.findById(fileProcessResult.getBook().getId()) .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); - notificationService.sendMessage(Topic.BOOK_ADD, processedBook); + notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getStatus()); metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); bookdropFileRepository.deleteById(bookdropFile.getId()); bookdropNotificationService.sendBookdropFileSummaryNotification(); @@ -333,7 +334,7 @@ public class BookDropService { .build(); } - private Book processFile(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { + private FileProcessResult processFile(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(library) .libraryPathEntity(path) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/event/BookEventBroadcaster.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/event/BookEventBroadcaster.java index f987b8d60..9e35cacd2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/event/BookEventBroadcaster.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/event/BookEventBroadcaster.java @@ -21,13 +21,12 @@ public class BookEventBroadcaster { 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); - }); + .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())); + }); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java index 402184644..42139c57d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java @@ -1,20 +1,26 @@ package com.adityachandel.booklore.service.fileprocessor; import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.DuplicateFileInfo; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.enums.FileProcessStatus; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookCreatorService; import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.metadata.MetadataMatchService; import com.adityachandel.booklore.util.FileService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; @Slf4j @@ -26,8 +32,16 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { protected final BookMapper bookMapper; protected final MetadataMatchService metadataMatchService; protected final FileService fileService; + @PersistenceContext + private EntityManager entityManager; - protected AbstractFileProcessor(BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, BookCreatorService bookCreatorService, BookMapper bookMapper, FileService fileService, MetadataMatchService metadataMatchService) { + + protected AbstractFileProcessor(BookRepository bookRepository, + BookAdditionalFileRepository bookAdditionalFileRepository, + BookCreatorService bookCreatorService, + BookMapper bookMapper, + FileService fileService, + MetadataMatchService metadataMatchService) { this.bookRepository = bookRepository; this.bookAdditionalFileRepository = bookAdditionalFileRepository; this.bookCreatorService = bookCreatorService; @@ -38,34 +52,96 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { @Transactional(propagation = Propagation.REQUIRES_NEW) @Override - public Book processFile(LibraryFile libraryFile) { + public FileProcessResult processFile(LibraryFile libraryFile) { Path path = libraryFile.getFullPath(); String fileName = path.getFileName().toString(); String hash = FileFingerprint.generateHash(path); + Optional duplicate = fileService.checkForDuplicateAndUpdateMetadataIfNeeded(libraryFile, hash, bookRepository, bookAdditionalFileRepository, bookMapper); + if (duplicate.isPresent()) { - return handleDuplicate(duplicate.get(), libraryFile); + return handleDuplicate(duplicate.get(), libraryFile, hash); } Long libraryId = libraryFile.getLibraryEntity().getId(); return bookRepository.findBookByFileNameAndLibraryId(fileName, libraryId) .map(bookMapper::toBook) - .orElseGet(() -> createAndMapBook(libraryFile, hash)); + .map(b -> new FileProcessResult(b, FileProcessStatus.DUPLICATE, createDuplicateInfo(b, libraryFile, hash))) + .orElseGet(() -> { + Book book = createAndMapBook(libraryFile, hash); + return new FileProcessResult(book, FileProcessStatus.NEW, null); + }); } - private Book handleDuplicate(Book bookDto, LibraryFile libraryFile) { - bookRepository.findById(bookDto.getId()) - .ifPresent(entity -> { - entity.setFileSubPath(libraryFile.getFileSubPath()); - entity.setFileName(libraryFile.getFileName()); - entity.setLibraryPath(libraryFile.getLibraryPathEntity()); - log.info("Duplicate file handled: bookId={} fileName='{}' libraryId={} subPath='{}'", - entity.getId(), - libraryFile.getFileName(), - libraryFile.getLibraryEntity().getId(), - libraryFile.getFileSubPath()); - }); - return bookDto; + private FileProcessResult handleDuplicate(Book bookDto, LibraryFile libraryFile, String hash) { + return bookRepository.findById(bookDto.getId()) + .map(entity -> { + boolean sameHash = Objects.equals(entity.getCurrentHash(), hash); + boolean sameFileName = Objects.equals(entity.getFileName(), libraryFile.getFileName()); + boolean sameSubPath = Objects.equals(entity.getFileSubPath(), libraryFile.getFileSubPath()); + boolean sameLibraryPath = Objects.equals(entity.getLibraryPath(), libraryFile.getLibraryPathEntity()); + + if (sameHash && sameFileName && sameSubPath && sameLibraryPath) { + return new FileProcessResult( + bookDto, + FileProcessStatus.DUPLICATE, + createDuplicateInfo(bookDto, libraryFile, hash) + ); + } + + boolean folderChanged = !sameSubPath; + boolean updated = false; + + if (!sameSubPath) { + entity.setFileSubPath(libraryFile.getFileSubPath()); + updated = true; + } + + if (!sameFileName) { + entity.setFileName(libraryFile.getFileName()); + updated = true; + } + + if (!sameLibraryPath) { + entity.setLibraryPath(libraryFile.getLibraryPathEntity()); + updated = true; + } + + entity.setCurrentHash(hash); + + /*if (folderChanged) { + log.info("Duplicate file found in different folder: bookId={} oldSubPath='{}' newSubPath='{}'", + entity.getId(), + bookDto.getFileSubPath(), + libraryFile.getFileSubPath()); + }*/ + + DuplicateFileInfo dupeInfo = createDuplicateInfo(bookMapper.toBook(entity), libraryFile, hash); + + if (updated) { + /*log.info("Duplicate file updated: bookId={} fileName='{}' libraryId={} subPath='{}'", + entity.getId(), + entity.getFileName(), + entity.getLibraryPath().getLibrary().getId(), + entity.getFileSubPath());*/ + entityManager.flush(); + entityManager.detach(entity); + return new FileProcessResult(bookMapper.toBook(entity), FileProcessStatus.UPDATED, dupeInfo); + } else { + entityManager.detach(entity); + return new FileProcessResult(bookMapper.toBook(entity), FileProcessStatus.DUPLICATE, dupeInfo); + } + }) + .orElse(new FileProcessResult(bookDto, FileProcessStatus.DUPLICATE, null)); + } + + private DuplicateFileInfo createDuplicateInfo(Book book, LibraryFile libraryFile, String hash) { + return new DuplicateFileInfo( + book.getId(), + libraryFile.getFileName(), + libraryFile.getFullPath().toString(), + hash + ); } private Book createAndMapBook(LibraryFile libraryFile, String hash) { @@ -77,4 +153,4 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { } protected abstract BookEntity processNewFile(LibraryFile libraryFile); -} +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/BookFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/BookFileProcessor.java index fb6dfa094..675fbeaba 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/BookFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/BookFileProcessor.java @@ -1,6 +1,6 @@ package com.adityachandel.booklore.service.fileprocessor; -import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.enums.BookFileType; @@ -9,6 +9,8 @@ import java.util.List; public interface BookFileProcessor { List getSupportedTypes(); - Book processFile(LibraryFile libraryFile); + + FileProcessResult processFile(LibraryFile libraryFile); + boolean generateCover(BookEntity bookEntity); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java new file mode 100644 index 000000000..3f4e2330e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java @@ -0,0 +1,157 @@ +package com.adityachandel.booklore.service.library; + +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.model.enums.BookFileExtension; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.util.FileService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookDeletionService { + + private final BookRepository bookRepository; + private final BookAdditionalFileRepository bookAdditionalFileRepository; + private final FileService fileService; + private final NotificationService notificationService; + + @PersistenceContext + private final EntityManager entityManager; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteRemovedAdditionalFiles(List additionalFileIds) { + if (additionalFileIds.isEmpty()) { + return; + } + + List additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds); + bookAdditionalFileRepository.deleteAll(additionalFiles); + entityManager.flush(); + entityManager.clear(); + + log.info("Deleted {} additional files from database", additionalFileIds.size()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processDeletedLibraryFiles(List deletedBookIds, List libraryFiles) { + if (deletedBookIds.isEmpty()) { + return; + } + + List books = bookRepository.findAllById(deletedBookIds); + List booksToDelete = new ArrayList<>(); + + for (BookEntity book : books) { + if (!tryPromoteAlternativeFormatToBook(book, libraryFiles)) { + booksToDelete.add(book.getId()); + } + } + + entityManager.flush(); + entityManager.clear(); + + if (!booksToDelete.isEmpty()) { + deleteRemovedBooks(booksToDelete); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteRemovedBooks(List bookIds) { + List books = bookRepository.findAllById(bookIds); + for (BookEntity book : books) { + try { + deleteDirectoryRecursively(Path.of(fileService.getImagesFolder(book.getId()))); + Path backupDir = Path.of(fileService.getBookMetadataBackupPath(book.getId())); + if (Files.exists(backupDir)) { + deleteDirectoryRecursively(backupDir); + } + } catch (Exception e) { + log.warn("Failed to clean up files for book ID {}: {}", book.getId(), e.getMessage()); + } + } + bookRepository.deleteAll(books); + entityManager.flush(); + entityManager.clear(); + notificationService.sendMessage(Topic.BOOKS_REMOVE, bookIds); + if (bookIds.size() > 1) log.info("Books removed: {}", bookIds); + } + + private boolean tryPromoteAlternativeFormatToBook(BookEntity book, List libraryFiles) { + List existingAlternativeFormats = findExistingAlternativeFormats(book, libraryFiles); + + if (existingAlternativeFormats.isEmpty()) { + return false; + } + + BookAdditionalFileEntity promotedFormat = existingAlternativeFormats.getFirst(); + promoteAlternativeFormatToBook(book, promotedFormat); + + bookAdditionalFileRepository.delete(promotedFormat); + + log.info("Promoted alternative format {} to main book for book ID {}", promotedFormat.getFileName(), book.getId()); + return true; + } + + private List findExistingAlternativeFormats(BookEntity book, List libraryFiles) { + Set currentFileNames = libraryFiles.stream() + .map(LibraryFile::getFileName) + .collect(Collectors.toSet()); + + if (book.getAdditionalFiles() == null) { + return Collections.emptyList(); + } + + return book.getAdditionalFiles().stream() + .filter(additionalFile -> AdditionalFileType.ALTERNATIVE_FORMAT.equals(additionalFile.getAdditionalFileType())) + .filter(additionalFile -> currentFileNames.contains(additionalFile.getFileName())) + .filter(additionalFile -> BookFileExtension.fromFileName(additionalFile.getFileName()).isPresent()) + .collect(Collectors.toList()); + } + + private void promoteAlternativeFormatToBook(BookEntity book, BookAdditionalFileEntity alternativeFormat) { + book.setFileName(alternativeFormat.getFileName()); + book.setFileSubPath(alternativeFormat.getFileSubPath()); + BookFileExtension.fromFileName(alternativeFormat.getFileName()) + .ifPresent(ext -> book.setBookType(ext.getType())); + + book.setFileSizeKb(alternativeFormat.getFileSizeKb()); + book.setCurrentHash(alternativeFormat.getCurrentHash()); + book.setInitialHash(alternativeFormat.getInitialHash()); + + bookRepository.save(book); + } + + private void deleteDirectoryRecursively(Path path) throws IOException { + if (Files.exists(path)) { + try (Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + log.warn("Failed to delete file or directory: {}", p, e); + } + }); + } + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookRestorationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookRestorationService.java new file mode 100644 index 000000000..6ed935e8e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookRestorationService.java @@ -0,0 +1,61 @@ +package com.adityachandel.booklore.service.library; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookRestorationService { + + private final BookRepository bookRepository; + private final BookMapper bookMapper; + private final NotificationService notificationService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void restoreDeletedBooks(List libraryFiles) { + if (libraryFiles.isEmpty()) return; + + LibraryEntity libraryEntity = libraryFiles.getFirst().getLibraryEntity(); + Set currentPaths = libraryFiles.stream() + .map(LibraryFile::getFullPath) + .collect(Collectors.toSet()); + + List toRestore = libraryEntity.getBookEntities().stream() + .filter(book -> Boolean.TRUE.equals(book.getDeleted())) + .filter(book -> currentPaths.contains(book.getFullFilePath())) + .collect(Collectors.toList()); + + if (toRestore.isEmpty()) return; + + toRestore.forEach(book -> { + book.setDeleted(false); + book.setDeletedAt(null); + book.setAddedOn(Instant.now()); + notificationService.sendMessage(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false)); + }); + bookRepository.saveAll(toRestore); + + List restoredIds = toRestore.stream() + .map(BookEntity::getId) + .toList(); + + log.info("Restored {} books in library: {}", restoredIds.size(), libraryEntity.getName()); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FileAsBookProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FileAsBookProcessor.java index f77fe03ff..2fb1a3278 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FileAsBookProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FileAsBookProcessor.java @@ -1,10 +1,13 @@ package com.adityachandel.booklore.service.library; -import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.DuplicateFileInfo; +import com.adityachandel.booklore.model.FileProcessResult; +import com.adityachandel.booklore.model.dto.DuplicateFileNotification; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.FileProcessStatus; +import com.adityachandel.booklore.model.enums.LibraryScanMode; import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.service.NotificationService; import com.adityachandel.booklore.service.event.BookEventBroadcaster; @@ -15,12 +18,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; -import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification; - -import com.adityachandel.booklore.model.enums.LibraryScanMode; - @AllArgsConstructor @Component @Slf4j @@ -28,6 +28,7 @@ public class FileAsBookProcessor implements LibraryFileProcessor { private final BookEventBroadcaster bookEventBroadcaster; private final BookFileProcessorRegistry processorRegistry; + private final NotificationService notificationService; @Override public LibraryScanMode getScanMode() { @@ -39,16 +40,40 @@ public class FileAsBookProcessor implements LibraryFileProcessor { public void processLibraryFiles(List libraryFiles, LibraryEntity libraryEntity) { for (LibraryFile libraryFile : libraryFiles) { log.info("Processing file: {}", libraryFile.getFileName()); - Book book = processLibraryFile(libraryFile); - if (book != null) { - bookEventBroadcaster.broadcastBookAddEvent(book); - log.info("Processed file: {}", libraryFile.getFileName()); + + FileProcessResult result = processLibraryFile(libraryFile); + + if (result != null) { + if (result.getDuplicate() != null) { + DuplicateFileInfo dupe = result.getDuplicate(); + + DuplicateFileNotification notification = DuplicateFileNotification.builder() + .libraryId(libraryEntity.getId()) + .libraryName(libraryEntity.getName()) + .fileId(dupe.getBookId()) + .fileName(dupe.getFileName()) + .fullPath(dupe.getFullPath()) + .hash(dupe.getHash()) + .timestamp(Instant.now()) + .build(); + + log.info("Duplicate file detected: {}", notification); + + notificationService.sendMessage(Topic.DUPLICATE_FILE, notification); + } + + if (result.getStatus() != FileProcessStatus.DUPLICATE) { + bookEventBroadcaster.broadcastBookAddEvent(result.getBook()); + log.info("Processed file: {}", libraryFile.getFileName()); + } } } + + log.info("Finished processing library '{}'", libraryEntity.getName()); } @Transactional - protected Book processLibraryFile(LibraryFile libraryFile) { + protected FileProcessResult processLibraryFile(LibraryFile libraryFile) { BookFileType type = libraryFile.getBookFileType(); if (type == null) { log.warn("Unsupported file type for file: {}", libraryFile.getFileName()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java index ecca57af4..7da56d438 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java @@ -1,6 +1,6 @@ package com.adityachandel.booklore.service.library; -import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; @@ -47,20 +47,17 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { @Override public boolean supportsSupplementaryFiles() { - // This processor supports supplementary files, as it processes all files in the folder. return true; } @Override public void processLibraryFiles(List libraryFiles, LibraryEntity libraryEntity) { - // Group files by their directory path Map> filesByDirectory = libraryFiles.stream() .collect(Collectors.groupingBy(libraryFile -> libraryFile.getFullPath().getParent())); log.info("Processing {} directories with {} total files for library: {}", filesByDirectory.size(), libraryFiles.size(), libraryEntity.getName()); - // Process each directory var sortedDirectories = filesByDirectory.entrySet() .stream() .sorted(Map.Entry.comparingByKey()) @@ -91,7 +88,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { return new GetOrCreateBookResult(existingBook, filesInDirectory); } - // No existing book, check parent directories Optional parentBook = findBookInParentDirectories(directoryPath, libraryEntity); if (parentBook.isPresent()) { log.debug("Found parent book for directory {}: {}", directoryPath, parentBook.get().getFileName()); @@ -113,7 +109,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { } private Optional findExistingBookInDirectory(Path directoryPath, LibraryEntity libraryEntity) { - // Find books in all library paths for this library return libraryEntity.getLibraryPaths().stream() .flatMap(libPath -> { String filesSearchPath = Path.of(libPath.getPath()) @@ -157,7 +152,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { } private Optional createNewBookFromDirectory(Path directoryPath, List filesInDirectory, LibraryEntity libraryEntity) { - // Find the best candidate for the main book file Optional mainBookFile = findBestMainBookFile(filesInDirectory, libraryEntity); if (mainBookFile.isEmpty()) { @@ -170,20 +164,17 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { try { log.info("Creating new book from file: {}", bookFile.getFileName()); - // Create the main book BookFileProcessor processor = bookFileProcessorRegistry.getProcessorOrThrow(bookFile.getBookFileType()); - Book book = processor.processFile(bookFile); + FileProcessResult result = processor.processFile(bookFile); - if (book != null) { - bookEventBroadcaster.broadcastBookAddEvent(book); + if (result.getBook() != null) { + bookEventBroadcaster.broadcastBookAddEvent(result.getBook()); - // Find the created book entity - BookEntity bookEntity = bookRepository.getReferenceById(book.getId()); + BookEntity bookEntity = bookRepository.getReferenceById(result.getBook().getId()); if (bookEntity.getFullFilePath().equals(bookFile.getFullPath())) { log.info("Successfully created new book: {}", bookEntity.getFileName()); } else { - log.warn("Found duplicate book with different path: {} vs {}", - bookEntity.getFullFilePath(), bookFile.getFullPath()); + log.warn("Found duplicate book with different path: {} vs {}", bookEntity.getFullFilePath(), bookFile.getFullPath()); } return Optional.of(new CreateBookResult(bookEntity, bookFile)); @@ -206,7 +197,7 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { .min(Comparator.comparingInt(f -> { BookFileType bookFileType = f.getBookFileType(); return bookFileType == defaultBookFormat - ? -1 // Prefer the default format + ? -1 : bookFileType.ordinal(); })); } @@ -222,7 +213,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { } private void createAdditionalFileIfNotExists(BookEntity bookEntity, LibraryFile file, AdditionalFileType fileType) { - // Check if an additional file already exists Optional existingFile = bookAdditionalFileRepository .findByLibraryPath_IdAndFileSubPathAndFileName( file.getLibraryPathEntity().getId(), file.getFileSubPath(), file.getFileName()); @@ -232,7 +222,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { return; } - // Create a new additional file String hash = FileFingerprint.generateHash(file.getFullPath()); BookAdditionalFileEntity additionalFile = BookAdditionalFileEntity.builder() .book(bookEntity) @@ -251,7 +240,6 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { log.debug("Successfully created additional file: {}", file.getFileName()); } catch (Exception e) { - // Remove an additional file from the book entity if its creation fails bookEntity.getAdditionalFiles().removeIf(a -> a.equals(additionalFile)); log.error("Error creating additional file {}: {}", file.getFileName(), e.getMessage(), e); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java index 77a11051c..f0ada059c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java @@ -1,21 +1,19 @@ package com.adityachandel.booklore.service.library; import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; 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.AdditionalFileType; import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -25,7 +23,6 @@ import java.io.IOException; import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -39,11 +36,12 @@ public class LibraryProcessingService { private final LibraryRepository libraryRepository; private final NotificationService notificationService; - private final BookRepository bookRepository; private final BookAdditionalFileRepository bookAdditionalFileRepository; - private final FileService fileService; - private final BookMapper bookMapper; private final LibraryFileProcessorRegistry fileProcessorRegistry; + private final BookRestorationService bookRestorationService; + private final BookDeletionService bookDeletionService; + @PersistenceContext + private final EntityManager entityManager; @Transactional public void processLibrary(long libraryId) throws IOException { @@ -64,48 +62,19 @@ public class LibraryProcessingService { List additionalFileIds = detectDeletedAdditionalFiles(libraryFiles, libraryEntity); if (!additionalFileIds.isEmpty()) { log.info("Detected {} removed additional files in library: {}", additionalFileIds.size(), libraryEntity.getName()); - deleteRemovedAdditionalFiles(additionalFileIds); + bookDeletionService.deleteRemovedAdditionalFiles(additionalFileIds); } List bookIds = detectDeletedBookIds(libraryFiles, libraryEntity); if (!bookIds.isEmpty()) { log.info("Detected {} removed books in library: {}", bookIds.size(), libraryEntity.getName()); - processDeletedLibraryFiles(bookIds, libraryFiles); + bookDeletionService.processDeletedLibraryFiles(bookIds, libraryFiles); } - restoreDeletedBooks(libraryFiles); + bookRestorationService.restoreDeletedBooks(libraryFiles); + entityManager.clear(); processor.processLibraryFiles(detectNewBookPaths(libraryFiles, libraryEntity), libraryEntity); notificationService.sendMessage(Topic.LOG, createLogNotification("Finished refreshing library: " + libraryEntity.getName())); } - private void restoreDeletedBooks(List libraryFiles) { - if (libraryFiles.isEmpty()) return; - - LibraryEntity libraryEntity = libraryFiles.get(0).getLibraryEntity(); - Set currentPaths = libraryFiles.stream() - .map(LibraryFile::getFullPath) - .collect(Collectors.toSet()); - - List toRestore = libraryEntity.getBookEntities().stream() - .filter(book -> Boolean.TRUE.equals(book.getDeleted())) - .filter(book -> currentPaths.contains(book.getFullFilePath())) - .collect(Collectors.toList()); - - if (toRestore.isEmpty()) return; - - toRestore.forEach(book -> { - book.setDeleted(false); - book.setDeletedAt(null); - book.setAddedOn(Instant.now()); - notificationService.sendMessage(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false)); - }); - bookRepository.saveAll(toRestore); - - List restoredIds = toRestore.stream() - .map(BookEntity::getId) - .toList(); - - log.info("Restored {} books in library: {}", restoredIds.size(), libraryEntity.getName()); - } - public void processLibraryFiles(List libraryFiles, LibraryEntity libraryEntity) { LibraryFileProcessor processor = fileProcessorRegistry.getProcessor(libraryEntity); processor.processLibraryFiles(libraryFiles, libraryEntity); @@ -157,119 +126,6 @@ public class LibraryProcessingService { .collect(Collectors.toList()); } - @Transactional - protected void deleteRemovedAdditionalFiles(List additionalFileIds) { - if (additionalFileIds.isEmpty()) { - return; - } - - List additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds); - bookAdditionalFileRepository.deleteAll(additionalFiles); - - log.info("Deleted {} additional files from database", additionalFileIds.size()); - } - - @Transactional - protected void processDeletedLibraryFiles(List deletedBookIds, List libraryFiles) { - if (deletedBookIds.isEmpty()) { - return; - } - - List books = bookRepository.findAllById(deletedBookIds); - List booksToDelete = new ArrayList<>(); - - for (BookEntity book : books) { - if (!tryPromoteAlternativeFormatToBook(book, libraryFiles)) { - booksToDelete.add(book.getId()); - } - } - - if (!booksToDelete.isEmpty()) { - deleteRemovedBooks(booksToDelete); - } - } - - protected boolean tryPromoteAlternativeFormatToBook(BookEntity book, List libraryFiles) { - // Find existing alternative formats for this book - List existingAlternativeFormats = findExistingAlternativeFormats(book, libraryFiles); - - if (existingAlternativeFormats.isEmpty()) { - return false; // No alternative formats to promote - } - - // Promote the first alternative format to become the main book - BookAdditionalFileEntity promotedFormat = existingAlternativeFormats.getFirst(); - promoteAlternativeFormatToBook(book, promotedFormat); - - // Remove the promoted format from additional files - bookAdditionalFileRepository.delete(promotedFormat); - - log.info("Promoted alternative format {} to main book for book ID {}", promotedFormat.getFileName(), book.getId()); - return true; - } - - private List findExistingAlternativeFormats(BookEntity book, List libraryFiles) { - Set currentFileNames = libraryFiles.stream() - .map(LibraryFile::getFileName) - .collect(Collectors.toSet()); - - if (book.getAdditionalFiles() == null) { - return Collections.emptyList(); - } - - return book.getAdditionalFiles().stream() - .filter(additionalFile -> AdditionalFileType.ALTERNATIVE_FORMAT.equals(additionalFile.getAdditionalFileType())) - .filter(additionalFile -> currentFileNames.contains(additionalFile.getFileName())) - .filter(additionalFile -> BookFileExtension.fromFileName(additionalFile.getFileName()).isPresent()) - .collect(Collectors.toList()); - } - - private void promoteAlternativeFormatToBook(BookEntity book, BookAdditionalFileEntity alternativeFormat) { - book.setFileName(alternativeFormat.getFileName()); - book.setFileSubPath(alternativeFormat.getFileSubPath()); - BookFileExtension.fromFileName(alternativeFormat.getFileName()) - .ifPresent(ext -> book.setBookType(ext.getType())); - - book.setFileSizeKb(alternativeFormat.getFileSizeKb()); - book.setCurrentHash(alternativeFormat.getCurrentHash()); - book.setInitialHash(alternativeFormat.getInitialHash()); - - bookRepository.save(book); - } - - @Transactional - protected void deleteRemovedBooks(List bookIds) { - List books = bookRepository.findAllById(bookIds); - for (BookEntity book : books) { - try { - deleteDirectoryRecursively(Path.of(fileService.getImagesFolder(book.getId()))); - Path backupDir = Path.of(fileService.getBookMetadataBackupPath(book.getId())); - if (Files.exists(backupDir)) { - deleteDirectoryRecursively(backupDir); - } - } catch (Exception e) { - log.warn("Failed to clean up files for book ID {}: {}", book.getId(), e.getMessage()); - } - } - bookRepository.deleteAll(books); - notificationService.sendMessage(Topic.BOOKS_REMOVE, bookIds); - if (bookIds.size() > 1) log.info("Books removed: {}", bookIds); - } - - private void deleteDirectoryRecursively(Path path) throws IOException { - if (Files.exists(path)) { - try (Stream walk = Files.walk(path)) { - walk.sorted(Comparator.reverseOrder()).forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - log.warn("Failed to delete file or directory: {}", p, e); - } - }); - } - } - } - private List getLibraryFiles(LibraryEntity libraryEntity, LibraryFileProcessor processor) throws IOException { List allFiles = new ArrayList<>(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java index 75d26ec78..045ef0484 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.upload; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.AdditionalFileMapper; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; @@ -14,6 +15,7 @@ import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.FileProcessStatus; import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; @@ -82,7 +84,7 @@ public class FileUploadService { .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); Path tempPath = Files.createTempFile("upload-", Objects.requireNonNull(file.getOriginalFilename())); - Book book; + FileProcessResult result; boolean wePaused = false; if (!monitoringService.isPaused()) { @@ -113,10 +115,12 @@ public class FileUploadService { log.info("File uploaded to final location: {}", finalPath); - book = processFile(finalFile.getName(), libraryEntity, libraryPathEntity, finalFile, fileExt.getType()); - notificationService.sendMessage(Topic.BOOK_ADD, book); + result = processFile(finalFile.getName(), libraryEntity, libraryPathEntity, finalFile, fileExt.getType()); + if (result != null && result.getStatus() != FileProcessStatus.DUPLICATE) { + notificationService.sendMessage(Topic.BOOK_ADD, result.getBook()); + } - return book; + return result.getBook(); } catch (IOException e) { throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); @@ -281,7 +285,7 @@ public class FileUploadService { } } - private Book processFile(String fileName, LibraryEntity libraryEntity, LibraryPathEntity libraryPathEntity, File storageFile, BookFileType type) { + private FileProcessResult processFile(String fileName, LibraryEntity libraryEntity, LibraryPathEntity libraryPathEntity, File storageFile, BookFileType type) { String subPath = FileUtils.getRelativeSubPath(libraryPathEntity.getPath(), storageFile.toPath()); LibraryFile libraryFile = LibraryFile.builder() diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java index 2364b6646..127a8dc79 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java @@ -27,7 +27,8 @@ import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BookServiceDeleteBooksTest { diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessorTest.java new file mode 100644 index 000000000..ec2082a18 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessorTest.java @@ -0,0 +1,511 @@ +package com.adityachandel.booklore.service.fileprocessor; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.DuplicateFileInfo; +import com.adityachandel.booklore.model.FileProcessResult; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +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.BookFileType; +import com.adityachandel.booklore.model.enums.FileProcessStatus; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.BookCreatorService; +import com.adityachandel.booklore.service.FileFingerprint; +import com.adityachandel.booklore.service.metadata.MetadataMatchService; +import com.adityachandel.booklore.util.FileService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AbstractFileProcessorTest { + + @Mock + BookRepository bookRepository; + @Mock + BookAdditionalFileRepository bookAdditionalFileRepository; + @Mock + BookCreatorService bookCreatorService; + @Mock + BookMapper bookMapper; + @Mock + MetadataMatchService metadataMatchService; + @Mock + FileService fileService; + @Mock + EntityManager entityManager; + + TestFileProcessor processor; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + processor = new TestFileProcessor( + bookRepository, + bookAdditionalFileRepository, + bookCreatorService, + bookMapper, + fileService, + metadataMatchService + ); + + // Inject EntityManager via reflection + try { + var field = AbstractFileProcessor.class.getDeclaredField("entityManager"); + field.setAccessible(true); + field.set(processor, entityManager); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void processFile_shouldReturnDuplicate_whenDuplicateFoundByFileService() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + Book duplicateBook = createMockBook(1L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded( + eq(libraryFile), eq("hash1"), eq(bookRepository), eq(bookAdditionalFileRepository), eq(bookMapper))) + .thenReturn(Optional.of(duplicateBook)); + + BookEntity bookEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity()); + when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity)); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE); + assertThat(result.getDuplicate()).isNotNull(); + assertThat(result.getDuplicate().getBookId()).isEqualTo(1L); + } + } + + @Test + void processFile_shouldReturnDuplicate_whenBookFoundByFileNameAndLibraryId() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + BookEntity existingEntity = createMockBookEntity(2L, "file.pdf", "hash2", "sub", libraryFile.getLibraryPathEntity()); + Book existingBook = createMockBook(2L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash2"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.empty()); + when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L)) + .thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(existingBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE); + assertThat(result.getBook()).isEqualTo(existingBook); + assertThat(result.getDuplicate()).isNotNull(); + } + } + + @Test + void processFile_shouldReturnNew_whenNoDuplicateFound() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + BookEntity newEntity = createMockBookEntity(3L, "file.pdf", "hash3", "sub", libraryFile.getLibraryPathEntity()); + Book newBook = createMockBook(3L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash3"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.empty()); + when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L)) + .thenReturn(Optional.empty()); + when(metadataMatchService.calculateMatchScore(any())).thenReturn(85F); + when(bookMapper.toBook(newEntity)).thenReturn(newBook); + + processor.setProcessNewFileResult(newEntity); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.NEW); + assertThat(result.getBook()).isEqualTo(newBook); + assertThat(result.getDuplicate()).isNull(); + verify(bookCreatorService).saveConnections(newEntity); + verify(metadataMatchService).calculateMatchScore(newEntity); + } + } + + @Test + void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentMetadata() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + libraryFile.setFileSubPath("new-sub"); + + Book duplicateBook = createMockBook(1L, "file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "old-sub", libraryFile.getLibraryPathEntity()); + Book updatedBook = createMockBook(1L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED); + assertThat(result.getBook()).isEqualTo(updatedBook); + assertThat(existingEntity.getFileSubPath()).isEqualTo("new-sub"); + verify(entityManager).flush(); + verify(entityManager).detach(existingEntity); + } + } + + @Test + void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentFileName() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + libraryFile.setFileName("new-file.pdf"); + + Book duplicateBook = createMockBook(1L, "old-file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "old-file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity()); + Book updatedBook = createMockBook(1L, "new-file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED); + assertThat(result.getBook()).isEqualTo(updatedBook); + assertThat(existingEntity.getFileName()).isEqualTo("new-file.pdf"); + verify(entityManager).flush(); + verify(entityManager).detach(existingEntity); + } + } + + @Test + void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentLibraryPath() { + // Given + LibraryEntity library = LibraryEntity.builder().id(2L).build(); + LibraryPathEntity newLibraryPath = LibraryPathEntity.builder() + .id(2L) + .library(library) + .path("/new-path") + .build(); + + LibraryFile libraryFile = LibraryFile.builder() + .fileName("file.pdf") + .fileSubPath("sub") + .bookFileType(BookFileType.PDF) + .libraryEntity(library) + .libraryPathEntity(newLibraryPath) + .build(); + + Book duplicateBook = createMockBook(1L, "file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub", + LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).build()).path("/old-path").build()); + Book updatedBook = createMockBook(1L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED); + assertThat(result.getBook()).isEqualTo(updatedBook); + assertThat(existingEntity.getLibraryPath()).isEqualTo(newLibraryPath); + verify(entityManager).flush(); + verify(entityManager).detach(existingEntity); + } + } + + @Test + void processFile_shouldReturnDuplicate_whenDuplicateFoundWithSameMetadata() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + Book duplicateBook = createMockBook(1L, "file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity()); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE); + assertThat(result.getBook()).isEqualTo(duplicateBook); + assertThat(result.getDuplicate()).isNotNull(); + assertThat(result.getDuplicate().getBookId()).isEqualTo(1L); + verify(entityManager, never()).flush(); + } + } + + @Test + void processFile_shouldReturnDuplicateFromFallback_whenDuplicateBookNotFoundInRepository() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + Book duplicateBook = createMockBook(1L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.empty()); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE); + assertThat(result.getBook()).isEqualTo(duplicateBook); + assertThat(result.getDuplicate()).isNull(); + } + } + + @Test + void processFile_shouldUpdateHashEvenWhenOtherMetadataUnchanged() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + Book duplicateBook = createMockBook(1L, "file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "old-hash", "sub", libraryFile.getLibraryPathEntity()); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("new-hash"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(duplicateBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(existingEntity.getCurrentHash()).isEqualTo("new-hash"); + verify(entityManager).detach(existingEntity); + } + } + + @Test + void processFile_shouldHandleNullFileSubPath() { + // Given + LibraryEntity library = LibraryEntity.builder().id(1L).build(); + LibraryPathEntity libraryPath = LibraryPathEntity.builder() + .id(1L) + .library(library) + .path("/tmp") + .build(); + + LibraryFile libraryFile = LibraryFile.builder() + .fileName("file.pdf") + .fileSubPath("") // Use empty string instead of null to avoid NPE in Paths.get + .bookFileType(BookFileType.PDF) + .libraryEntity(library) + .libraryPathEntity(libraryPath) + .build(); + + BookEntity newEntity = createMockBookEntity(3L, "file.pdf", "hash3", "", libraryFile.getLibraryPathEntity()); + Book newBook = createMockBook(3L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash3"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.empty()); + when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L)) + .thenReturn(Optional.empty()); + when(metadataMatchService.calculateMatchScore(any())).thenReturn(90F); + when(bookMapper.toBook(newEntity)).thenReturn(newBook); + + processor.setProcessNewFileResult(newEntity); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.NEW); + assertThat(result.getBook()).isEqualTo(newBook); + } + } + + @Test + void processFile_shouldHandleMultipleMetadataChangesSimultaneously() { + // Given + LibraryEntity newLibrary = LibraryEntity.builder().id(2L).build(); + LibraryPathEntity newLibraryPath = LibraryPathEntity.builder() + .id(2L) + .library(newLibrary) + .path("/new-path") + .build(); + + LibraryFile libraryFile = LibraryFile.builder() + .fileName("new-file.pdf") + .fileSubPath("new-sub") + .bookFileType(BookFileType.PDF) + .libraryEntity(newLibrary) + .libraryPathEntity(newLibraryPath) + .build(); + + Book duplicateBook = createMockBook(1L, "old-file.pdf"); + BookEntity existingEntity = createMockBookEntity(1L, "old-file.pdf", "hash1", "old-sub", + LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).build()).build()); + Book updatedBook = createMockBook(1L, "new-file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(duplicateBook)); + when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity)); + when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED); + assertThat(existingEntity.getFileName()).isEqualTo("new-file.pdf"); + assertThat(existingEntity.getFileSubPath()).isEqualTo("new-sub"); + assertThat(existingEntity.getLibraryPath()).isEqualTo(newLibraryPath); + verify(entityManager).flush(); + verify(entityManager).detach(existingEntity); + } + } + + @Test + void createDuplicateInfo_shouldCreateCorrectDuplicateInfo() { + // Given + LibraryFile libraryFile = createMockLibraryFile(); + Book book = createMockBook(1L, "file.pdf"); + + try (MockedStatic fingerprintMock = mockStatic(FileFingerprint.class)) { + fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1"); + + when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any())) + .thenReturn(Optional.of(book)); + when(bookRepository.findById(1L)).thenReturn(Optional.of( + createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity()))); + + // When + FileProcessResult result = processor.processFile(libraryFile); + + // Then + DuplicateFileInfo duplicateInfo = result.getDuplicate(); + assertThat(duplicateInfo).isNotNull(); + assertThat(duplicateInfo.getBookId()).isEqualTo(1L); + assertThat(duplicateInfo.getFileName()).isEqualTo("file.pdf"); + assertThat(duplicateInfo.getFullPath()).contains("/tmp", "sub", "file.pdf"); + } + } + + // Helper methods + private LibraryFile createMockLibraryFile() { + LibraryEntity library = LibraryEntity.builder().id(1L).build(); + LibraryPathEntity libraryPath = LibraryPathEntity.builder() + .id(1L) + .library(library) + .path("/tmp") + .build(); + + return LibraryFile.builder() + .fileName("file.pdf") + .fileSubPath("sub") + .bookFileType(BookFileType.PDF) + .libraryEntity(library) + .libraryPathEntity(libraryPath) + .build(); + } + + private Book createMockBook(Long id, String fileName) { + return Book.builder() + .id(id) + .fileName(fileName) + .fileSubPath("sub") + .build(); + } + + private BookEntity createMockBookEntity(Long id, String fileName, String hash, String subPath, LibraryPathEntity libraryPath) { + return BookEntity.builder() + .id(id) + .fileName(fileName) + .currentHash(hash) + .fileSubPath(subPath) + .libraryPath(libraryPath) + .build(); + } + + // Test implementation of AbstractFileProcessor + static class TestFileProcessor extends AbstractFileProcessor { + private BookEntity processNewFileResult; + + public TestFileProcessor(BookRepository bookRepository, + BookAdditionalFileRepository bookAdditionalFileRepository, + BookCreatorService bookCreatorService, + BookMapper bookMapper, + FileService fileService, + MetadataMatchService metadataMatchService) { + super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService); + } + + @Override + protected BookEntity processNewFile(LibraryFile libraryFile) { + return processNewFileResult; + } + + @Override + public List getSupportedTypes() { + return List.of(BookFileType.PDF); + } + + @Override + public boolean generateCover(BookEntity bookEntity) { + return false; + } + + public void setProcessNewFileResult(BookEntity entity) { + this.processNewFileResult = entity; + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FileAsBookProcessorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FileAsBookProcessorTest.java index cf033a02d..f1f3780bd 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FileAsBookProcessorTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FileAsBookProcessorTest.java @@ -1,6 +1,8 @@ package com.adityachandel.booklore.service.library; import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.FileProcessResult; +import com.adityachandel.booklore.model.enums.FileProcessStatus; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; @@ -58,7 +60,7 @@ class FileAsBookProcessorTest { LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); List libraryFiles = new ArrayList<>(); - + LibraryFile file1 = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -66,7 +68,7 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.EPUB) .build(); - + LibraryFile file2 = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -74,30 +76,32 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.PDF) .build(); - + libraryFiles.add(file1); libraryFiles.add(file2); - + Book book1 = Book.builder() .fileName("book1.epub") .title("Book 1") .bookType(BookFileType.EPUB) .build(); - + Book book2 = Book.builder() .fileName("book2.pdf") .title("Book 2") .bookType(BookFileType.PDF) .build(); - + when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor); when(processorRegistry.getProcessorOrThrow(BookFileType.PDF)).thenReturn(bookFileProcessor); - when(bookFileProcessor.processFile(file1)).thenReturn(book1); - when(bookFileProcessor.processFile(file2)).thenReturn(book2); - + when(bookFileProcessor.processFile(file1)) + .thenReturn(new FileProcessResult(book1, FileProcessStatus.NEW, null)); + when(bookFileProcessor.processFile(file2)) + .thenReturn(new FileProcessResult(book2, FileProcessStatus.NEW, null)); + // When fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity); - + // Then verify(bookEventBroadcaster, times(2)).broadcastBookAddEvent(bookCaptor.capture()); @@ -113,7 +117,7 @@ class FileAsBookProcessorTest { LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); List libraryFiles = new ArrayList<>(); - + LibraryFile validFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -121,7 +125,7 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.EPUB) .build(); - + LibraryFile invalidFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -129,22 +133,23 @@ class FileAsBookProcessorTest { .fileSubPath("docs") .bookFileType(null) .build(); - + libraryFiles.add(validFile); libraryFiles.add(invalidFile); - + Book book = Book.builder() .fileName("book.epub") .title("Valid Book") .bookType(BookFileType.EPUB) .build(); - + when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor); - when(bookFileProcessor.processFile(validFile)).thenReturn(book); - + when(bookFileProcessor.processFile(validFile)) + .thenReturn(new FileProcessResult(book, FileProcessStatus.NEW, null)); + // When fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity); - + // Then verify(bookEventBroadcaster, times(1)).broadcastBookAddEvent(book); verify(processorRegistry, times(1)).getProcessorOrThrow(any()); @@ -155,10 +160,10 @@ class FileAsBookProcessorTest { // Given LibraryEntity libraryEntity = new LibraryEntity(); List libraryFiles = new ArrayList<>(); - + // When fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity); - + // Then verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any()); verify(processorRegistry, never()).getProcessorOrThrow(any()); @@ -170,7 +175,7 @@ class FileAsBookProcessorTest { LibraryEntity libraryEntity = new LibraryEntity(); LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); - + LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -178,10 +183,10 @@ class FileAsBookProcessorTest { .fileSubPath("docs") .bookFileType(null) .build(); - + // When - Book result = fileAsBookProcessor.processLibraryFile(libraryFile); - + FileProcessResult result = fileAsBookProcessor.processLibraryFile(libraryFile); + // Then assertThat(result).isNull(); verify(processorRegistry, never()).getProcessorOrThrow(any()); @@ -193,7 +198,7 @@ class FileAsBookProcessorTest { LibraryEntity libraryEntity = new LibraryEntity(); LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); - + LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -201,21 +206,24 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.EPUB) .build(); - + Book expectedBook = Book.builder() .fileName("book.epub") .title("Test Book") .bookType(BookFileType.EPUB) .build(); - + when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor); - when(bookFileProcessor.processFile(libraryFile)).thenReturn(expectedBook); - + when(bookFileProcessor.processFile(libraryFile)) + .thenReturn(new FileProcessResult(expectedBook, FileProcessStatus.NEW, null)); + // When - Book result = fileAsBookProcessor.processLibraryFile(libraryFile); - + FileProcessResult result = fileAsBookProcessor.processLibraryFile(libraryFile); + // Then - assertThat(result).isEqualTo(expectedBook); + assertThat(result).isNotNull(); + assertThat(result.getBook()).isEqualTo(expectedBook); + verify(processorRegistry).getProcessorOrThrow(BookFileType.EPUB); verify(bookFileProcessor).processFile(libraryFile); } @@ -226,7 +234,7 @@ class FileAsBookProcessorTest { LibraryEntity libraryEntity = new LibraryEntity(); LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); - + LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -234,13 +242,13 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.PDF) .build(); - + when(processorRegistry.getProcessorOrThrow(BookFileType.PDF)).thenReturn(bookFileProcessor); when(bookFileProcessor.processFile(libraryFile)).thenReturn(null); - + // When - Book result = fileAsBookProcessor.processLibraryFile(libraryFile); - + FileProcessResult result = fileAsBookProcessor.processLibraryFile(libraryFile); + // Then assertThat(result).isNull(); } @@ -252,7 +260,7 @@ class FileAsBookProcessorTest { LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); List libraryFiles = new ArrayList<>(); - + LibraryFile file = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -260,15 +268,15 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.EPUB) .build(); - + libraryFiles.add(file); - + when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor); when(bookFileProcessor.processFile(file)).thenReturn(null); - + // When fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity); - + // Then verify(bookEventBroadcaster, never()).broadcastBookAddEvent(any()); } @@ -280,7 +288,7 @@ class FileAsBookProcessorTest { LibraryPathEntity libraryPathEntity = new LibraryPathEntity(); libraryPathEntity.setPath("/library/path"); List libraryFiles = new ArrayList<>(); - + LibraryFile epubFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -288,7 +296,7 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.EPUB) .build(); - + LibraryFile pdfFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -296,7 +304,7 @@ class FileAsBookProcessorTest { .fileSubPath("books") .bookFileType(BookFileType.PDF) .build(); - + LibraryFile cbzFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -304,7 +312,7 @@ class FileAsBookProcessorTest { .fileSubPath("comics") .bookFileType(BookFileType.CBX) .build(); - + LibraryFile cbrFile = LibraryFile.builder() .libraryEntity(libraryEntity) .libraryPathEntity(libraryPathEntity) @@ -312,44 +320,48 @@ class FileAsBookProcessorTest { .fileSubPath("comics") .bookFileType(BookFileType.CBX) .build(); - + libraryFiles.add(epubFile); libraryFiles.add(pdfFile); libraryFiles.add(cbzFile); libraryFiles.add(cbrFile); - + Book epubBook = Book.builder() .fileName("book.epub") .bookType(BookFileType.EPUB) .build(); - + Book pdfBook = Book.builder() .fileName("book.pdf") .bookType(BookFileType.PDF) .build(); - + Book cbzBook = Book.builder() .fileName("comic.cbz") .bookType(BookFileType.CBX) .build(); - + Book cbrBook = Book.builder() .fileName("comic.cbr") .bookType(BookFileType.CBX) .build(); - + when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor); when(processorRegistry.getProcessorOrThrow(BookFileType.PDF)).thenReturn(bookFileProcessor); when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(bookFileProcessor); - when(bookFileProcessor.processFile(epubFile)).thenReturn(epubBook); - when(bookFileProcessor.processFile(pdfFile)).thenReturn(pdfBook); - when(bookFileProcessor.processFile(cbzFile)).thenReturn(cbzBook); - when(bookFileProcessor.processFile(cbrFile)).thenReturn(cbrBook); - + when(bookFileProcessor.processFile(epubFile)) + .thenReturn(new FileProcessResult(epubBook, FileProcessStatus.NEW, null)); + when(bookFileProcessor.processFile(pdfFile)) + .thenReturn(new FileProcessResult(pdfBook, FileProcessStatus.NEW, null)); + when(bookFileProcessor.processFile(cbzFile)) + .thenReturn(new FileProcessResult(cbzBook, FileProcessStatus.NEW, null)); + when(bookFileProcessor.processFile(cbrFile)) + .thenReturn(new FileProcessResult(cbrBook, FileProcessStatus.NEW, null)); + // When fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity); - + // Then verify(bookEventBroadcaster, times(4)).broadcastBookAddEvent(any(Book.class)); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java index fa565c889..824080688 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java @@ -1,10 +1,12 @@ package com.adityachandel.booklore.service.library; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.settings.LibraryFile; 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.FileProcessStatus; import com.adityachandel.booklore.model.enums.LibraryScanMode; import com.adityachandel.booklore.service.event.BookEventBroadcaster; import com.adityachandel.booklore.service.event.AdminEventBroadcaster; @@ -118,7 +120,7 @@ class FolderAsBookFileProcessorTest { when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF)) .thenReturn(mockBookFileProcessor); when(mockBookFileProcessor.processFile(any(LibraryFile.class))) - .thenReturn(createdBook); + .thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null)); when(bookRepository.getReferenceById(createdBook.getId())) .thenReturn(bookEntity); when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString())) @@ -230,7 +232,7 @@ class FolderAsBookFileProcessorTest { when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.EPUB)) .thenReturn(mockBookFileProcessor); when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.epub")))) - .thenReturn(createdBook); + .thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null)); when(bookRepository.getReferenceById(createdBook.getId())) .thenReturn(bookEntity); when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString())) @@ -275,7 +277,7 @@ class FolderAsBookFileProcessorTest { when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF)) .thenReturn(mockBookFileProcessor); when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.pdf")))) - .thenReturn(createdBook); + .thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null)); when(bookRepository.getReferenceById(createdBook.getId())) .thenReturn(bookEntity); when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString())) diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java index a7a731185..b5d6a9264 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java @@ -4,11 +4,13 @@ import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.APIException; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.AdditionalFileMapperImpl; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.settings.AppSettings; 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.enums.FileProcessStatus; import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; @@ -110,11 +112,11 @@ class FileUploadServiceTest { Files.write(tempDir.resolve("dup.epub"), new byte[]{1, 2, 3}); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFileBookDrop(file)) - .satisfies(ex -> { - assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_ALREADY_EXISTS.getStatus()); - assertThat(ex.getMessage()).isEqualTo(ApiError.FILE_ALREADY_EXISTS.getMessage()); - }); + .isThrownBy(() -> service.uploadFileBookDrop(file)) + .satisfies(ex -> { + assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_ALREADY_EXISTS.getStatus()); + assertThat(ex.getMessage()).isEqualTo(ApiError.FILE_ALREADY_EXISTS.getMessage()); + }); } @Test @@ -122,11 +124,11 @@ class FileUploadServiceTest { MockMultipartFile file = new MockMultipartFile("file", "bad.txt", "text/plain", "x".getBytes()); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFileBookDrop(file)) - .satisfies(ex -> { - assertThat(ex.getStatus()).isEqualTo(ApiError.INVALID_FILE_FORMAT.getStatus()); - assertThat(ex.getMessage()).contains("Invalid file format, only pdf and epub are supported"); - }); + .isThrownBy(() -> service.uploadFileBookDrop(file)) + .satisfies(ex -> { + assertThat(ex.getStatus()).isEqualTo(ApiError.INVALID_FILE_FORMAT.getStatus()); + assertThat(ex.getMessage()).contains("Invalid file format, only pdf and epub are supported"); + }); } @Test @@ -138,11 +140,11 @@ class FileUploadServiceTest { when(appSettingService.getAppSettings()).thenReturn(small); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFileBookDrop(file)) - .satisfies(ex -> { - assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_TOO_LARGE.getStatus()); - assertThat(ex.getMessage()).contains("1"); - }); + .isThrownBy(() -> service.uploadFileBookDrop(file)) + .satisfies(ex -> { + assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_TOO_LARGE.getStatus()); + assertThat(ex.getMessage()).contains("1"); + }); } @Test @@ -151,8 +153,8 @@ class FileUploadServiceTest { when(libraryRepository.findById(42L)).thenReturn(Optional.empty()); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFile(file, 42L, 1L)) - .satisfies(ex -> assertThat(ex.getStatus()).isEqualTo(ApiError.LIBRARY_NOT_FOUND.getStatus())); + .isThrownBy(() -> service.uploadFile(file, 42L, 1L)) + .satisfies(ex -> assertThat(ex.getStatus()).isEqualTo(ApiError.LIBRARY_NOT_FOUND.getStatus())); } @Test @@ -164,8 +166,8 @@ class FileUploadServiceTest { when(libraryRepository.findById(42L)).thenReturn(Optional.of(lib)); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFile(file, 42L, 99L)) - .satisfies(ex -> assertThat(ex.getStatus()).isEqualTo(ApiError.INVALID_LIBRARY_PATH.getStatus())); + .isThrownBy(() -> service.uploadFile(file, 42L, 99L)) + .satisfies(ex -> assertThat(ex.getStatus()).isEqualTo(ApiError.INVALID_LIBRARY_PATH.getStatus())); } @Test @@ -185,11 +187,11 @@ class FileUploadServiceTest { when(libraryRepository.findById(1L)).thenReturn(Optional.of(lib)); assertThatExceptionOfType(APIException.class) - .isThrownBy(() -> service.uploadFile(file, 1L, 1L)) - .satisfies(ex -> { - assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_READ_ERROR.getStatus()); - assertThat(ex.getMessage()).contains("Error reading files from path"); - }); + .isThrownBy(() -> service.uploadFile(file, 1L, 1L)) + .satisfies(ex -> { + assertThat(ex.getStatus()).isEqualTo(ApiError.FILE_READ_ERROR.getStatus()); + assertThat(ex.getMessage()).contains("Error reading files from path"); + }); } @Test @@ -206,17 +208,20 @@ class FileUploadServiceTest { when(libraryRepository.findById(7L)).thenReturn(Optional.of(lib)); BookFileProcessor proc = mock(BookFileProcessor.class); - Book stubBook = Book.builder().build(); - stubBook.setTitle("X"); + FileProcessResult fileProcessResult = FileProcessResult.builder() + .book(Book.builder().build()) + .status(FileProcessStatus.NEW) + .build(); + when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(proc); - when(proc.processFile(any())).thenReturn(stubBook); + when(proc.processFile(any())).thenReturn(fileProcessResult); Book result = service.uploadFile(file, 7L, 2L); - assertThat(result).isSameAs(stubBook); + assertThat(result).isSameAs(fileProcessResult.getBook()); Path moved = tempDir.resolve("book.cbz"); assertThat(Files.exists(moved)).isTrue(); - verify(notificationService).sendMessage(eq(Topic.BOOK_ADD), same(stubBook)); + verify(notificationService).sendMessage(eq(Topic.BOOK_ADD), same(fileProcessResult.getBook())); verifyNoInteractions(pdfMetadataExtractor, epubMetadataExtractor); } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java index 53b2ac49b..325154e22 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java @@ -2,13 +2,11 @@ package com.adityachandel.booklore.util.builder; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.mapper.BookMapperImpl; +import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.*; -import com.adityachandel.booklore.model.enums.AdditionalFileType; -import com.adityachandel.booklore.model.enums.BookFileExtension; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.LibraryScanMode; +import com.adityachandel.booklore.model.enums.*; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.FileFingerprint; @@ -74,7 +72,7 @@ public class LibraryTestBuilder { lenient().when(bookFileProcessorMock.processFile(any(LibraryFile.class))) .then(invocation -> { LibraryFile libraryFile = invocation.getArgument(0); - return processFile(libraryFile); + return processFileResult(libraryFile); }); lenient().when(bookRepositoryMock.getReferenceById(anyLong())) .thenAnswer(invocation -> { @@ -275,7 +273,15 @@ public class LibraryTestBuilder { return hexString.toString(); } - private Book processFile(LibraryFile libraryFile) { + private FileProcessResult processFileResult(LibraryFile libraryFile) { + var book = processBook(libraryFile); + return FileProcessResult.builder() + .book(book) + .status(FileProcessStatus.NEW) + .build(); + } + + private Book processBook(LibraryFile libraryFile) { var hash = computeFileHash(libraryFile.getFullPath()); long id = libraryFiles.indexOf(libraryFile) + 1L; diff --git a/booklore-ui/src/app/app.component.ts b/booklore-ui/src/app/app.component.ts index 1d0ec60d5..849000bea 100644 --- a/booklore-ui/src/app/app.component.ts +++ b/booklore-ui/src/app/app.component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnInit} from '@angular/core'; +import {Component, inject, OnInit, OnDestroy} from '@angular/core'; import {RxStompService} from './shared/websocket/rx-stomp.service'; import {BookService} from './book/service/book.service'; import {NotificationEventService} from './shared/websocket/notification-event.service'; @@ -12,6 +12,9 @@ import {MetadataBatchProgressNotification} from './core/model/metadata-batch-pro import {MetadataProgressService} from './core/service/metadata-progress-service'; import {BookdropFileNotification, BookdropFileService} from './bookdrop/bookdrop-file.service'; import {TaskEventService} from './shared/websocket/task-event.service'; +import {DuplicateFileNotification} from './shared/websocket/model/duplicate-file-notification.model'; +import {DuplicateFileService} from './shared/websocket/duplicate-file.service'; +import {Subscription} from 'rxjs'; @Component({ selector: 'app-root', @@ -20,9 +23,11 @@ import {TaskEventService} from './shared/websocket/task-event.service'; standalone: true, imports: [ConfirmDialog, Toast, RouterOutlet] }) -export class AppComponent implements OnInit { +export class AppComponent implements OnInit, OnDestroy { loading = true; + private subscriptions: Subscription[] = []; + private subscriptionsInitialized = false; // Prevent multiple subscription setups private authInit = inject(AuthInitializationService); private bookService = inject(BookService); private rxStompService = inject(RxStompService); @@ -30,37 +35,70 @@ export class AppComponent implements OnInit { private metadataProgressService = inject(MetadataProgressService); private bookdropFileService = inject(BookdropFileService); private taskEventService = inject(TaskEventService); - private appConfigService = inject(AppConfigService); + private duplicateFileService = inject(DuplicateFileService); + private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized ngOnInit(): void { this.authInit.initialized$.subscribe(ready => { this.loading = !ready; - }); - this.rxStompService.watch('/user/queue/book-add').subscribe(msg => - this.bookService.handleNewlyCreatedBook(JSON.parse(msg.body)) - ); - this.rxStompService.watch('/user/queue/books-remove').subscribe(msg => - this.bookService.handleRemovedBookIds(JSON.parse(msg.body)) - ); - this.rxStompService.watch('/user/queue/book-metadata-update').subscribe(msg => - this.bookService.handleBookUpdate(JSON.parse(msg.body)) - ); - this.rxStompService.watch('/user/queue/book-metadata-batch-update').subscribe(msg => - this.bookService.handleMultipleBookUpdates(JSON.parse(msg.body)) - ); - this.rxStompService.watch('/user/queue/book-metadata-batch-progress').subscribe(msg => - this.metadataProgressService.handleIncomingProgress(JSON.parse(msg.body) as MetadataBatchProgressNotification) - ); - this.rxStompService.watch('/user/queue/log').subscribe(msg => { - const logNotification = parseLogNotification(msg.body); - this.notificationEventService.handleNewNotification(logNotification); - }); - this.rxStompService.watch('/user/queue/task').subscribe(msg => - this.taskEventService.handleTaskMessage(parseTaskMessage(msg.body)) - ); - this.rxStompService.watch('/user/queue/bookdrop-file').subscribe(msg => { - const notification = JSON.parse(msg.body) as BookdropFileNotification; - this.bookdropFileService.handleIncomingFile(notification); + if (ready && !this.subscriptionsInitialized) { + this.setupWebSocketSubscriptions(); + this.subscriptionsInitialized = true; + } }); } + + private setupWebSocketSubscriptions(): void { + this.subscriptions.push( + this.rxStompService.watch('/user/queue/book-add').subscribe(msg => + this.bookService.handleNewlyCreatedBook(JSON.parse(msg.body)) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/books-remove').subscribe(msg => + this.bookService.handleRemovedBookIds(JSON.parse(msg.body)) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/book-metadata-update').subscribe(msg => + this.bookService.handleBookUpdate(JSON.parse(msg.body)) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/book-metadata-batch-update').subscribe(msg => + this.bookService.handleMultipleBookUpdates(JSON.parse(msg.body)) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/book-metadata-batch-progress').subscribe(msg => + this.metadataProgressService.handleIncomingProgress(JSON.parse(msg.body) as MetadataBatchProgressNotification) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/log').subscribe(msg => { + const logNotification = parseLogNotification(msg.body); + this.notificationEventService.handleNewNotification(logNotification); + }) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/task').subscribe(msg => + this.taskEventService.handleTaskMessage(parseTaskMessage(msg.body)) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/duplicate-file').subscribe(msg => + this.duplicateFileService.addDuplicateFile(JSON.parse(msg.body) as DuplicateFileNotification) + ) + ); + this.subscriptions.push( + this.rxStompService.watch('/user/queue/bookdrop-file').subscribe(msg => { + const notification = JSON.parse(msg.body) as BookdropFileNotification; + this.bookdropFileService.handleIncomingFile(notification); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(sub => sub.unsubscribe()); + } } diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.html b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.html new file mode 100644 index 000000000..07de7c3ac --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.html @@ -0,0 +1,84 @@ +
+
+ +

{{ totalRecords }} duplicate file groups have been detected in your library. To maintain data integrity and ensure optimal performance, please review and remove these duplicate entries from your file system.

+
+ +
+ + + + + +
+
+ + Hash: + {{ group.hash }} + ({{ group.files.length }} files) +
+ +
+ @for (file of (group.files || []); track file.fullPath) { +
+
+ +
+
+
{{ file.fileName }}
+
{{ file.fullPath }}
+
+ + {{ file.libraryName }} + {{ file.timestamp | date:'short' }} +
+
+
+ } +
+
+ + +
+ + + + +
+ + No duplicate files detected +
+ + +
+
+
+ +
+
+ + +
+ +
+ + +
+
+
diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.scss b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.scss new file mode 100644 index 000000000..3be2e8d3b --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.scss @@ -0,0 +1,235 @@ +.dialog-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.table-container { + flex: 1; + overflow: hidden; + margin-bottom: 100px; + + ::ng-deep .p-datatable { + height: 100%; + + .p-datatable-wrapper { + height: 100%; + } + + .p-datatable-tbody > tr > td { + border: none !important; + padding: 0.5rem 0.5rem; + } + + .p-datatable-tbody > tr { + border: none !important; + } + } +} + +.bottom-fixed-area { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--surface-ground); + z-index: 1000; + display: flex; + flex-direction: column; +} + +.paginator-container { + ::ng-deep .p-paginator { + border: none; + border-bottom: 1px solid var(--border-color); + } +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 1rem; +} + +.duplicate-files-content { + overflow: hidden; +} + +.hash-group { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 1rem; + margin: 0.5rem 0; +} + +.hash-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + i { + color: var(--primary-color); + font-size: 0.9rem; + } + + .hash-label { + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + font-size: 0.9rem; + } + + .hash-value { + font-family: 'Courier New', monospace; + color: rgba(255, 255, 255, 0.7); + font-size: 1rem; + font-weight: 900; + background: rgba(255, 255, 255, 0.05); + padding: 0.25rem 0.5rem; + border-radius: 4px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-count { + background: var(--primary-color); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + } +} + +.files-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.file-item-compact { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; + margin-left: 1rem; +} + +.file-icon { + background: var(--primary-color); + color: white; + width: 32px; + height: 32px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + i { + font-size: 0.9rem; + } +} + +.file-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filename { + font-family: 'Courier New', monospace; + font-weight: 600; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.9); + word-break: break-word; + line-height: 1.2; +} + +.file-path { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.7); + font-family: 'Courier New', monospace; + word-break: break-all; + line-height: 1.2; + max-height: 2.4em; + overflow: hidden; +} + +.library-info { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + margin-top: 0.1rem; + + i { + color: var(--primary-color); + font-size: 0.85rem; + } + + .timestamp { + margin-left: auto; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); + } +} + +.empty-message { + text-align: center !important; + padding: 3rem 1rem !important; + + .empty-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + opacity: 0.6; + + i { + font-size: 1.5rem; + } + + span { + font-size: 0.875rem; + } + } +} + +.info-message { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + background: rgba(234, 88, 12, 0.1); + border: 1px solid rgba(234, 88, 12, 0.3); + border-radius: 0.375rem; + color: #fbbf24; + + i { + color: #f59e0b; + font-size: 1.125rem; + margin-top: 0.125rem; + flex-shrink: 0; + } + + p { + margin: 0; + line-height: 1.5; + font-size: 1rem; + } +} diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.ts b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.ts new file mode 100644 index 000000000..290f72ad0 --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-dialog.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { TooltipModule } from 'primeng/tooltip'; +import { PaginatorModule } from 'primeng/paginator'; +import { Observable, map } from 'rxjs'; +import {DuplicateFileNotification} from '../../../shared/websocket/model/duplicate-file-notification.model'; +import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.service'; + +@Component({ + selector: 'app-duplicate-files-dialog', + standalone: true, + imports: [CommonModule, TableModule, ButtonModule, TooltipModule, PaginatorModule], + templateUrl: './duplicate-files-dialog.component.html', + styleUrls: ['./duplicate-files-dialog.component.scss'] +}) +export class DuplicateFilesDialogComponent implements OnInit { + duplicateFiles$: Observable<{ hash: string; files: DuplicateFileNotification[] }[]>; + totalRecords = 0; + first = 0; + rows = 15; + + constructor( + public ref: DynamicDialogRef, + public config: DynamicDialogConfig, + private duplicateFileService: DuplicateFileService + ) { + this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe( + map((files: DuplicateFileNotification[] | null) => { + if (!files) return []; + + // Group files by hash + const groupedFiles = files.reduce((acc, file) => { + if (!acc[file.hash]) { + acc[file.hash] = []; + } + acc[file.hash].push(file); + return acc; + }, {} as { [hash: string]: DuplicateFileNotification[] }); + + const groups = Object.entries(groupedFiles).map(([hash, files]) => ({ + hash, + files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + })); + + this.totalRecords = groups.length; + return groups.slice(this.first, this.first + this.rows); + }) + ); + } + + ngOnInit() {} + + onPageChange(event: any) { + this.first = event.first; + this.rows = event.rows; + this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe( + map((files: DuplicateFileNotification[] | null) => { + if (!files) return []; + + const groupedFiles = files.reduce((acc, file) => { + if (!acc[file.hash]) { + acc[file.hash] = []; + } + acc[file.hash].push(file); + return acc; + }, {} as { [hash: string]: DuplicateFileNotification[] }); + + const groups = Object.entries(groupedFiles).map(([hash, files]) => ({ + hash, + files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + })); + + this.totalRecords = groups.length; + return groups.slice(this.first, this.first + this.rows); + }) + ); + } + + acknowledgeAndClose() { + this.duplicateFileService.clearDuplicateFiles(); + this.ref.close(); + } +} diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.html b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.html new file mode 100644 index 000000000..7b8681961 --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.html @@ -0,0 +1,22 @@ +@if (duplicateFilesCount$ | async; as count) { + @if (count > 0) { +
+
+
+ + {{ count }} duplicate files detected +
+ +
+ + +
+ } +} diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss new file mode 100644 index 000000000..6c1ca1ece --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss @@ -0,0 +1,8 @@ +.duplicate-files-content { + max-height: 500px; + overflow: hidden; +} + +.live-border { + border: 0.5px solid var(--primary-color); +} diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.ts b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.ts new file mode 100644 index 000000000..c614cd6f3 --- /dev/null +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.ts @@ -0,0 +1,80 @@ +import {Component, inject, OnDestroy} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {DialogModule} from 'primeng/dialog'; +import {ButtonModule} from 'primeng/button'; +import {TableModule} from 'primeng/table'; +import {TagModule} from 'primeng/tag'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.service'; +import {DuplicateFileNotification} from '../../../shared/websocket/model/duplicate-file-notification.model'; +import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {DuplicateFilesDialogComponent} from './duplicate-files-dialog.component'; + +@Component({ + selector: 'app-duplicate-files-notification', + standalone: true, + imports: [CommonModule, DialogModule, ButtonModule, TableModule, TagModule], + templateUrl: './duplicate-files-notification.component.html', + styleUrls: ['./duplicate-files-notification.component.scss'], + providers: [DialogService] +}) +export class DuplicateFilesNotificationComponent implements OnDestroy { + displayDialog = false; + + private duplicateFileService = inject(DuplicateFileService); + private ref: DynamicDialogRef | undefined; + + duplicateFiles$ = this.duplicateFileService.duplicateFiles$; + duplicateFilesCount$: Observable = this.duplicateFiles$.pipe( + map(files => files?.length || 0) + ); + + constructor( + private dialogService: DialogService + ) { + } + + openDialog() { + this.ref = this.dialogService.open(DuplicateFilesDialogComponent, { + header: 'Duplicate Files Detected', + width: '80dvw', + height: '75dvh', + contentStyle: {overflow: 'hidden'}, + maximizable: true, + modal: true, + data: { + duplicateFiles$: this.duplicateFiles$ + } + }); + + this.ref.onClose.subscribe((result: any) => { + if (result) { + // Handle any result from dialog if needed + } + }); + } + + closeDialog() { + this.displayDialog = false; + } + + clearAllDuplicates() { + this.duplicateFileService.clearDuplicateFiles(); + this.closeDialog(); + } + + removeDuplicate(file: DuplicateFileNotification) { + this.duplicateFileService.removeDuplicateFile(file.fullPath, file.libraryId); + } + + formatTimestamp(timestamp: string): string { + return new Date(timestamp).toLocaleString(); + } + + ngOnDestroy() { + if (this.ref) { + this.ref.close(); + } + } +} diff --git a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss index 59773448d..2636cb2e7 100644 --- a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss +++ b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss @@ -1,5 +1,3 @@ .live-border { - background: var(--card-background); - border: 1px solid var(--primary-color); - border-radius: 0.5rem; + border: 0.5px solid var(--primary-color); } diff --git a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss index 76336d9e6..a434fb8e7 100644 --- a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss +++ b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss @@ -1,6 +1,4 @@ .live-border { - background: var(--card-background); - border: 1px solid var(--primary-color); - border-radius: 0.5rem; + border: 0.5px solid var(--primary-color); } diff --git a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html index 1d1c30865..5b92eb200 100644 --- a/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html +++ b/booklore-ui/src/app/core/component/unified-notification-popover-component/unified-notification-popover-component.html @@ -1,5 +1,8 @@