mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Fix database lockout caused by duplicate files in library (#1102)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum FileProcessStatus {
|
||||
NEW,
|
||||
DUPLICATE,
|
||||
UPDATED
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.duplicate-files-content {
|
||||
max-height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-border {
|
||||
border: 0.5px solid var(--primary-color);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface DuplicateFileNotification {
|
||||
libraryId: number;
|
||||
libraryName: string;
|
||||
fileId: number;
|
||||
fileName: string;
|
||||
fullPath: string;
|
||||
timestamp: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user