Fix database lockout caused by duplicate files in library (#1102)

This commit is contained in:
Aditya Chandel
2025-09-06 21:22:59 -06:00
committed by GitHub
parent c8e78d3bc8
commit 074d8d6e69
36 changed files with 1766 additions and 359 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.adityachandel.booklore.model.enums;
public enum FileProcessStatus {
NEW,
DUPLICATE,
UPDATED
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BookFileType> getSupportedTypes();
Book processFile(LibraryFile libraryFile);
FileProcessResult processFile(LibraryFile libraryFile);
boolean generateCover(BookEntity bookEntity);
}

View File

@@ -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<Long> additionalFileIds) {
if (additionalFileIds.isEmpty()) {
return;
}
List<BookAdditionalFileEntity> 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<Long> deletedBookIds, List<LibraryFile> libraryFiles) {
if (deletedBookIds.isEmpty()) {
return;
}
List<BookEntity> books = bookRepository.findAllById(deletedBookIds);
List<Long> 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<Long> bookIds) {
List<BookEntity> 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<LibraryFile> libraryFiles) {
List<BookAdditionalFileEntity> 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<BookAdditionalFileEntity> findExistingAlternativeFormats(BookEntity book, List<LibraryFile> libraryFiles) {
Set<String> 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<Path> 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);
}
});
}
}
}
}

View File

@@ -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<LibraryFile> libraryFiles) {
if (libraryFiles.isEmpty()) return;
LibraryEntity libraryEntity = libraryFiles.getFirst().getLibraryEntity();
Set<Path> currentPaths = libraryFiles.stream()
.map(LibraryFile::getFullPath)
.collect(Collectors.toSet());
List<BookEntity> 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<Long> restoredIds = toRestore.stream()
.map(BookEntity::getId)
.toList();
log.info("Restored {} books in library: {}", restoredIds.size(), libraryEntity.getName());
}
}

View File

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

View File

@@ -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<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
// Group files by their directory path
Map<Path, List<LibraryFile>> 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<BookEntity> 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<BookEntity> 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<CreateBookResult> createNewBookFromDirectory(Path directoryPath, List<LibraryFile> filesInDirectory, LibraryEntity libraryEntity) {
// Find the best candidate for the main book file
Optional<LibraryFile> 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<BookAdditionalFileEntity> 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);

View File

@@ -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<Long> 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<Long> 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<LibraryFile> libraryFiles) {
if (libraryFiles.isEmpty()) return;
LibraryEntity libraryEntity = libraryFiles.get(0).getLibraryEntity();
Set<Path> currentPaths = libraryFiles.stream()
.map(LibraryFile::getFullPath)
.collect(Collectors.toSet());
List<BookEntity> 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<Long> restoredIds = toRestore.stream()
.map(BookEntity::getId)
.toList();
log.info("Restored {} books in library: {}", restoredIds.size(), libraryEntity.getName());
}
public void processLibraryFiles(List<LibraryFile> 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<Long> additionalFileIds) {
if (additionalFileIds.isEmpty()) {
return;
}
List<BookAdditionalFileEntity> additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds);
bookAdditionalFileRepository.deleteAll(additionalFiles);
log.info("Deleted {} additional files from database", additionalFileIds.size());
}
@Transactional
protected void processDeletedLibraryFiles(List<Long> deletedBookIds, List<LibraryFile> libraryFiles) {
if (deletedBookIds.isEmpty()) {
return;
}
List<BookEntity> books = bookRepository.findAllById(deletedBookIds);
List<Long> 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<LibraryFile> libraryFiles) {
// Find existing alternative formats for this book
List<BookAdditionalFileEntity> 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<BookAdditionalFileEntity> findExistingAlternativeFormats(BookEntity book, List<LibraryFile> libraryFiles) {
Set<String> 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<Long> bookIds) {
List<BookEntity> 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<Path> 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<LibraryFile> getLibraryFiles(LibraryEntity libraryEntity, LibraryFileProcessor processor) throws IOException {
List<LibraryFile> allFiles = new ArrayList<>();

View File

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

View File

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

View File

@@ -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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<FileFingerprint> 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<BookFileType> getSupportedTypes() {
return List.of(BookFileType.PDF);
}
@Override
public boolean generateCover(BookEntity bookEntity) {
return false;
}
public void setProcessNewFileResult(BookEntity entity) {
this.processNewFileResult = entity;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
<div class="dialog-container">
<div class="info-message">
<i class="pi pi-exclamation-triangle"></i>
<p>{{ 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.</p>
</div>
<div class="table-container">
<p-table
#dataTable
[value]="(duplicateFiles$ | async) || []"
[scrollable]="true"
scrollHeight="100%"
[paginator]="false"
[rows]="15"
[rowHover]="true">
<ng-template pTemplate="body" let-group>
<tr>
<td>
<div class="hash-group">
<div class="hash-header">
<i class="pi pi-copy"></i>
<span class="hash-label">Hash:</span>
<span class="hash-value">{{ group.hash }}</span>
<span class="file-count">({{ group.files.length }} files)</span>
</div>
<div class="files-list">
@for (file of (group.files || []); track file.fullPath) {
<div class="file-item-compact">
<div class="file-icon">
<i class="pi pi-file"></i>
</div>
<div class="file-details">
<div class="filename">{{ file.fileName }}</div>
<div class="file-path" [title]="file.fullPath">{{ file.fullPath }}</div>
<div class="library-info">
<i class="pi pi-database"></i>
<span>{{ file.libraryName }}</span>
<span class="timestamp">{{ file.timestamp | date:'short' }}</span>
</div>
</div>
</div>
}
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td class="empty-message">
<div class="empty-content">
<i class="pi pi-info-circle"></i>
<span>No duplicate files detected</span>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
<div class="bottom-fixed-area">
<div class="paginator-container">
<p-paginator
[first]="first"
[rows]="rows"
[totalRecords]="totalRecords"
[showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} duplicate file groups"
(onPageChange)="onPageChange($event)">
</p-paginator>
</div>
<div class="dialog-actions">
<p-button
label="Acknowledge"
icon="pi pi-check"
(click)="acknowledgeAndClose()">
</p-button>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
@if (duplicateFilesCount$ | async; as count) {
@if (count > 0) {
<div class="flex flex-col p-4 space-y-2 live-border">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-exclamation-triangle text-yellow-500"></i>
<span class="font-normal text-zinc-200">{{ count }} duplicate files detected</span>
</div>
<p-tag [value]="count.toString()" severity="warn" [rounded]="true"></p-tag>
</div>
<p-button
label="View Details"
icon="pi pi-list"
outlined
size="small"
severity="info"
fluid
(click)="openDialog()">
</p-button>
</div>
}
}

View File

@@ -0,0 +1,8 @@
.duplicate-files-content {
max-height: 500px;
overflow: hidden;
}
.live-border {
border: 0.5px solid var(--primary-color);
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
<div class="metadata-progress-box flex gap-4 flex-col w-[25rem] max-h-[60vh] overflow-y-auto">
<app-live-notification-box/>
@if (hasDuplicateFiles$ | async) {
<app-duplicate-files-notification/>
}
@if (hasActiveTasks$ | async) {
<app-live-task-event-box/>
}

View File

@@ -8,6 +8,8 @@ import {map} from 'rxjs/operators';
import {AsyncPipe} from '@angular/common';
import {BookdropFilesWidgetComponent} from '../../../bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component';
import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service';
import {DuplicateFilesNotificationComponent} from '../duplicate-files-notification/duplicate-files-notification.component';
import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.service';
@Component({
selector: 'app-unified-notification-popover-component',
@@ -16,7 +18,8 @@ import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service';
LiveTaskEventBoxComponent,
MetadataProgressWidgetComponent,
AsyncPipe,
BookdropFilesWidgetComponent
BookdropFilesWidgetComponent,
DuplicateFilesNotificationComponent
],
templateUrl: './unified-notification-popover-component.html',
standalone: true,
@@ -25,6 +28,7 @@ import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service';
export class UnifiedNotificationBoxComponent {
metadataProgressService = inject(MetadataProgressService);
bookdropFileService = inject(BookdropFileService);
duplicateFileService = inject(DuplicateFileService);
taskEventService = inject(TaskEventService);
hasMetadataTasks$ = this.metadataProgressService.activeTasks$.pipe(
@@ -33,6 +37,10 @@ export class UnifiedNotificationBoxComponent {
hasPendingBookdropFiles$ = this.bookdropFileService.hasPendingFiles$;
hasDuplicateFiles$ = this.duplicateFileService.duplicateFiles$.pipe(
map(files => files && files.length > 0)
);
hasActiveTasks$ = this.taskEventService.tasks$.pipe(
map(tasks => tasks.length > 0)
);

View File

@@ -24,6 +24,7 @@ import {UnifiedNotificationBoxComponent} from '../../../core/component/unified-n
import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service';
import {DialogLauncherService} from '../../../dialog-launcher.service';
import {TaskEventService} from '../../../shared/websocket/task-event.service';
import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.service';
@Component({
selector: 'app-topbar',
@@ -62,12 +63,14 @@ export class AppTopBarComponent implements OnDestroy {
showPulse = false;
hasAnyTasks = false;
hasPendingBookdropFiles = false;
hasDuplicateFiles = false;
private eventTimer: any;
private destroy$ = new Subject<void>();
private latestTasks: { [taskId: string]: MetadataBatchProgressNotification } = {};
private latestHasPendingFiles = false;
private latestHasDuplicateFiles = false;
constructor(
public layoutService: LayoutService,
@@ -79,11 +82,13 @@ export class AppTopBarComponent implements OnDestroy {
private metadataProgressService: MetadataProgressService,
private bookdropFileService: BookdropFileService,
private dialogLauncher: DialogLauncherService,
private taskEventService: TaskEventService
private taskEventService: TaskEventService,
private duplicateFileService: DuplicateFileService
) {
this.subscribeToMetadataProgress();
this.subscribeToNotifications();
this.subscribeToTaskEvents();
this.subscribeToDuplicateFiles();
this.metadataProgressService.activeTasks$
.pipe(takeUntil(this.destroy$))
@@ -102,6 +107,15 @@ export class AppTopBarComponent implements OnDestroy {
this.updateCompletedTaskCount();
this.updateTaskVisibilityWithBookdrop();
});
this.duplicateFileService.duplicateFiles$
.pipe(takeUntil(this.destroy$))
.subscribe((duplicateFiles) => {
this.latestHasDuplicateFiles = duplicateFiles && duplicateFiles.length > 0;
this.hasDuplicateFiles = this.latestHasDuplicateFiles;
this.updateCompletedTaskCount();
this.updateTaskVisibilityWithDuplicates();
});
}
ngOnDestroy(): void {
@@ -174,6 +188,16 @@ export class AppTopBarComponent implements OnDestroy {
});
}
private subscribeToDuplicateFiles() {
this.duplicateFileService.duplicateFiles$
.pipe(takeUntil(this.destroy$))
.subscribe((duplicateFiles) => {
if (duplicateFiles && duplicateFiles.length > 0) {
this.triggerPulseEffect();
}
});
}
private triggerPulseEffect() {
this.showPulse = true;
clearTimeout(this.eventTimer);
@@ -185,7 +209,8 @@ export class AppTopBarComponent implements OnDestroy {
private updateCompletedTaskCount() {
const completedMetadataTasks = Object.values(this.latestTasks).filter(task => task.status === 'COMPLETED').length;
const bookdropFileTaskCount = this.latestHasPendingFiles ? 1 : 0;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount;
const duplicateFileTaskCount = this.latestHasDuplicateFiles ? 1 : 0;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount + duplicateFileTaskCount;
}
private updateTaskVisibility(tasks: { [taskId: string]: MetadataBatchProgressNotification }) {
@@ -196,6 +221,11 @@ export class AppTopBarComponent implements OnDestroy {
private updateTaskVisibilityWithBookdrop() {
this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasPendingBookdropFiles;
this.updateTaskVisibilityWithDuplicates();
}
private updateTaskVisibilityWithDuplicates() {
this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasDuplicateFiles;
}
get iconClass(): string {
@@ -208,7 +238,7 @@ export class AppTopBarComponent implements OnDestroy {
get iconColor(): string {
if (this.progressHighlight) return 'yellow';
if (this.showPulse) return 'red';
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles) return 'orange';
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) return 'orange';
return 'inherit';
}
@@ -218,7 +248,7 @@ export class AppTopBarComponent implements OnDestroy {
get shouldShowNotificationBadge(): boolean {
return (
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles) &&
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) &&
!this.progressHighlight &&
!this.showPulse
);

View File

@@ -0,0 +1,36 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';
import {DuplicateFileNotification} from './model/duplicate-file-notification.model';
@Injectable({
providedIn: 'root'
})
export class DuplicateFileService {
private duplicateFiles: DuplicateFileNotification[] = [];
private duplicateFilesSubject = new BehaviorSubject<DuplicateFileNotification[]>([]);
duplicateFiles$ = this.duplicateFilesSubject.asObservable();
addDuplicateFile(notification: DuplicateFileNotification) {
const exists = this.duplicateFiles.some(file =>
file.fullPath === notification.fullPath && file.libraryId === notification.libraryId
);
if (!exists) {
this.duplicateFiles.unshift(notification);
this.duplicateFilesSubject.next([...this.duplicateFiles]);
}
}
clearDuplicateFiles() {
this.duplicateFiles = [];
this.duplicateFilesSubject.next([]);
}
removeDuplicateFile(fullPath: string, libraryId: number) {
this.duplicateFiles = this.duplicateFiles.filter(file =>
!(file.fullPath === fullPath && file.libraryId === libraryId)
);
this.duplicateFilesSubject.next([...this.duplicateFiles]);
}
}

View File

@@ -0,0 +1,10 @@
export interface DuplicateFileNotification {
libraryId: number;
libraryName: string;
fileId: number;
fileName: string;
fullPath: string;
timestamp: string;
hash: string;
}