Allow duplicate files both within the same library and across different libraries (#1632)

This commit is contained in:
Aditya Chandel
2025-11-25 04:38:18 -07:00
committed by GitHub
parent c6126b5aea
commit 415207d91d
27 changed files with 23 additions and 1416 deletions

View File

@@ -1,15 +0,0 @@
package com.adityachandel.booklore.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Builder
@Getter
@AllArgsConstructor
public class DuplicateFileInfo {
private final Long bookId;
private final String fileName;
private final String fullPath;
private final String hash;
}

View File

@@ -14,6 +14,4 @@ import lombok.ToString;
public class FileProcessResult {
private final Book book;
private final FileProcessStatus status;
@Builder.Default
private final DuplicateFileInfo duplicate = null;
}

View File

@@ -1,20 +0,0 @@
package com.adityachandel.booklore.model.dto;
import lombok.*;
import java.time.Instant;
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
@Builder
public class DuplicateFileNotification {
private long libraryId;
private String libraryName;
private long fileId;
private String fileName;
private String fullPath;
private String hash;
private Instant timestamp;
}

View File

@@ -2,6 +2,5 @@ package com.adityachandel.booklore.model.enums;
public enum FileProcessStatus {
NEW,
DUPLICATE,
UPDATED
}

View File

@@ -13,7 +13,6 @@ 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"),
LOG("/queue/log"),
TASK_PROGRESS("/queue/task-progress");

View File

@@ -7,7 +7,6 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.security.core.parameters.P;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@@ -24,8 +23,6 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
Optional<BookEntity> findByCurrentHash(String currentHash);
Optional<BookEntity> findByCurrentHashAndDeletedTrue(String currentHash);
@Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
Set<Long> findBookIdsByLibraryId(@Param("libraryId") long libraryId);
@@ -43,10 +40,6 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
List<BookEntity> findAllWithMetadata();
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query(value = "SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
Page<BookEntity> findAllWithMetadata(Pageable pageable);
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
List<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> bookIds);

View File

@@ -1,7 +1,6 @@
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;
@@ -13,15 +12,11 @@ import com.adityachandel.booklore.service.book.BookCreatorService;
import com.adityachandel.booklore.service.file.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
public abstract class AbstractFileProcessor implements BookFileProcessor {
@@ -32,8 +27,6 @@ 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,
@@ -54,95 +47,9 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
@Override
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);
if (duplicate.isPresent()) {
return handleDuplicate(duplicate.get(), libraryFile, hash);
}
Long libraryId = libraryFile.getLibraryEntity().getId();
return bookRepository.findBookByFileNameAndLibraryId(fileName, libraryId)
.map(bookMapper::toBook)
.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 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());
entity.setLibrary(libraryFile.getLibraryEntity());
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
);
Book book = createAndMapBook(libraryFile, hash);
return new FileProcessResult(book, FileProcessStatus.NEW);
}
private Book createAndMapBook(LibraryFile libraryFile, String hash) {

View File

@@ -1,14 +1,10 @@
package com.adityachandel.booklore.service.library;
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.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;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
@@ -18,7 +14,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@AllArgsConstructor
@@ -44,28 +39,8 @@ public class FileAsBookProcessor implements LibraryFileProcessor {
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());
}
bookEventBroadcaster.broadcastBookAddEvent(result.getBook());
log.info("Processed file: {}", libraryFile.getFileName());
}
}

View File

@@ -55,11 +55,6 @@ public class BookFilePersistenceService {
notificationService.sendMessageToPermissions(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false), Set.of(ADMIN, MANIPULATE_LIBRARY));
}
@Transactional(readOnly = true)
public Optional<BookEntity> findByHash(String hash) {
return bookRepository.findByCurrentHash(hash);
}
String findMatchingLibraryPath(LibraryEntity libraryEntity, Path filePath) {
return libraryEntity.getLibraryPaths().stream()
.map(lp -> Paths.get(lp.getPath()).toAbsolutePath().normalize())

View File

@@ -36,16 +36,9 @@ public class BookFileTransactionalHandler {
private final LibraryRepository libraryRepository;
@Transactional()
public void handleNewBookFile(long libraryId, Path path, String currentHash) {
public void handleNewBookFile(long libraryId, Path path) {
LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
Optional<BookEntity> existingOpt = bookFilePersistenceService.findByHash(currentHash);
if (existingOpt.isPresent()) {
BookEntity existingBook = existingOpt.get();
bookFilePersistenceService.updatePathIfChanged(existingBook, libraryEntity, path, currentHash);
return;
}
String filePath = path.toString();
String fileName = path.getFileName().toString();
String libraryPath = bookFilePersistenceService.findMatchingLibraryPath(libraryEntity, path);

View File

@@ -118,8 +118,7 @@ public class LibraryFileEventProcessor {
private void handleFileCreate(LibraryEntity library, Path path) {
log.info("[FILE_CREATE] '{}'", path);
String hash = FileFingerprint.generateHash(path);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), path, hash);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), path);
}
private void handleFileDelete(LibraryEntity library, Path path) {
@@ -153,8 +152,7 @@ public class LibraryFileEventProcessor {
.filter(p -> isBookFile(p.getFileName().toString()))
.forEach(p -> {
try {
String hash = FileFingerprint.generateHash(p);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p, hash);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p);
} catch (Exception e) {
log.warn("[ERROR] Processing file '{}': {}", p, e.getMessage());
}

View File

@@ -2,18 +2,9 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.mapper.BookMapper;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -32,15 +23,11 @@ import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Comparator;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
@@ -50,9 +37,6 @@ import java.util.stream.Stream;
public class FileService {
private final AppProperties appProperties;
private final BookRepository bookRepository;
private final BookAdditionalFileRepository bookAdditionalFileRepository;
private final BookMapper bookMapper;
// @formatter:off
private static final String IMAGES_DIR = "images";
@@ -373,59 +357,6 @@ public class FileService {
// UTILITY METHODS
// ========================================
@Transactional
public Optional<Book> checkForDuplicateAndUpdateMetadataIfNeeded(LibraryFile libraryFile, String hash) {
if (StringUtils.isBlank(hash)) {
log.warn("Skipping file due to missing hash: {}", libraryFile.getFullPath());
return Optional.empty();
}
// First check for soft-deleted books with the same hash
Optional<BookEntity> softDeletedBook = bookRepository.findByCurrentHashAndDeletedTrue(hash);
if (softDeletedBook.isPresent()) {
BookEntity book = softDeletedBook.get();
log.info("Found soft-deleted book with same hash, undeleting: bookId={} file='{}'",
book.getId(), libraryFile.getFileName());
// Undelete the book
book.setDeleted(false);
book.setDeletedAt(null);
// Update file information
book.setFileName(libraryFile.getFileName());
book.setFileSubPath(libraryFile.getFileSubPath());
book.setLibraryPath(libraryFile.getLibraryPathEntity());
book.setLibrary(libraryFile.getLibraryEntity());
return Optional.of(bookMapper.toBook(book));
}
Optional<BookEntity> existingByHash = bookRepository.findByCurrentHash(hash);
if (existingByHash.isPresent()) {
BookEntity book = existingByHash.get();
String fileName = libraryFile.getFullPath().getFileName().toString();
if (!book.getFileName().equals(fileName)) {
book.setFileName(fileName);
}
if (!Objects.equals(book.getLibraryPath().getId(), libraryFile.getLibraryPathEntity().getId())) {
book.setLibraryPath(libraryFile.getLibraryPathEntity());
book.setFileSubPath(libraryFile.getFileSubPath());
}
return Optional.of(bookMapper.toBook(book));
}
Optional<BookAdditionalFileEntity> existingAdditionalFile = bookAdditionalFileRepository.findByAltFormatCurrentHash(hash);
if (existingAdditionalFile.isPresent()) {
BookAdditionalFileEntity additionalFile = existingAdditionalFile.get();
BookEntity book = additionalFile.getBook();
// Additional file might have a different name or path, so there is no need
// to update the file name or library path here
return Optional.of(bookMapper.toBook(book));
}
return Optional.empty();
}
public static String truncate(String input, int maxLength) {
if (input == null) return null;
if (maxLength <= 0) return "";

View File

@@ -1,534 +0,0 @@
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.book.BookCreatorService;
import com.adityachandel.booklore.service.file.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(
any(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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).watch(false).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).watch(false).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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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).watch(false).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(LibraryFile.class), any(String.class)))
.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).watch(false).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).watch(false).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(LibraryFile.class), any(String.class)))
.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(LibraryFile.class), any(String.class)))
.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).watch(false).build();
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
.id(1L)
.library(library)
.path("/tmp")
.build();
return LibraryFile.builder()
.fileName("file.pdf")
.fileSubPath("sub")
.bookFileType(BookFileType.PDF)
.libraryEntity(library)
.libraryPathEntity(libraryPath)
.build();
}
private Book createMockBook(Long id, String fileName) {
return Book.builder()
.id(id)
.fileName(fileName)
.fileSubPath("sub")
.build();
}
private BookEntity createMockBookEntity(Long id, String fileName, String hash, String subPath, LibraryPathEntity libraryPath) {
return BookEntity.builder()
.id(id)
.fileName(fileName)
.currentHash(hash)
.fileSubPath(subPath)
.libraryPath(libraryPath)
.build();
}
// Test implementation of AbstractFileProcessor
static class TestFileProcessor extends AbstractFileProcessor {
private BookEntity processNewFileResult;
public TestFileProcessor(BookRepository bookRepository,
BookAdditionalFileRepository bookAdditionalFileRepository,
BookCreatorService bookCreatorService,
BookMapper bookMapper,
FileService fileService,
MetadataMatchService metadataMatchService) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
}
@Override
protected BookEntity processNewFile(LibraryFile libraryFile) {
return processNewFileResult;
}
@Override
public List<BookFileType> getSupportedTypes() {
return List.of(BookFileType.PDF);
}
@Override
public boolean generateCover(BookEntity bookEntity) {
return false;
}
public void setProcessNewFileResult(BookEntity entity) {
this.processNewFileResult = entity;
}
}
}

View File

@@ -95,9 +95,9 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(processorRegistry.getProcessorOrThrow(BookFileType.PDF)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(file1))
.thenReturn(new FileProcessResult(book1, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book1, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(file2))
.thenReturn(new FileProcessResult(book2, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book2, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
@@ -145,7 +145,7 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(validFile))
.thenReturn(new FileProcessResult(book, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
@@ -215,7 +215,7 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(libraryFile))
.thenReturn(new FileProcessResult(expectedBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(expectedBook, FileProcessStatus.NEW));
// When
FileProcessResult result = fileAsBookProcessor.processLibraryFile(libraryFile);
@@ -351,13 +351,13 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(epubFile))
.thenReturn(new FileProcessResult(epubBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(epubBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(pdfFile))
.thenReturn(new FileProcessResult(pdfBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(pdfBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(cbzFile))
.thenReturn(new FileProcessResult(cbzBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(cbzBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(cbrFile))
.thenReturn(new FileProcessResult(cbrBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(cbrBook, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);

View File

@@ -120,7 +120,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(any(LibraryFile.class)))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
@@ -232,7 +232,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.EPUB))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.epub"))))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
@@ -277,7 +277,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.pdf"))))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))

View File

@@ -11,8 +11,6 @@ import {AppConfigService} from './shared/service/app-config.service';
import {MetadataBatchProgressNotification} from './shared/model/metadata-batch-progress.model';
import {MetadataProgressService} from './shared/service/metadata-progress-service';
import {BookdropFileNotification, BookdropFileService} from './features/bookdrop/service/bookdrop-file.service';
import {DuplicateFileNotification} from './shared/websocket/model/duplicate-file-notification.model';
import {DuplicateFileService} from './shared/websocket/duplicate-file.service';
import {Subscription} from 'rxjs';
import {DownloadProgressDialogComponent} from './shared/components/download-progress-dialog/download-progress-dialog.component';
import {TaskService, TaskProgressPayload} from './features/settings/task-management/task.service';
@@ -35,7 +33,6 @@ export class AppComponent implements OnInit, OnDestroy {
private notificationEventService = inject(NotificationEventService);
private metadataProgressService = inject(MetadataProgressService);
private bookdropFileService = inject(BookdropFileService);
private duplicateFileService = inject(DuplicateFileService);
private taskService = inject(TaskService);
private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized
@@ -86,11 +83,6 @@ export class AppComponent implements OnInit, OnDestroy {
this.notificationEventService.handleNewNotification(logNotification);
})
);
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;

View File

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

View File

@@ -1,235 +0,0 @@
.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 var(--p-content-border-color);
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 var(--p-content-border-color);
transition: all 0.2s ease;
margin-left: 1rem;
}
.file-icon {
background: var(--primary-color);
color: white;
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
i {
font-size: 0.9rem;
}
}
.file-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filename {
font-family: 'Courier New', monospace;
font-weight: 600;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
word-break: break-word;
line-height: 1.2;
}
.file-path {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.7);
font-family: 'Courier New', monospace;
word-break: break-all;
line-height: 1.2;
max-height: 2.4em;
overflow: hidden;
}
.library-info {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 0.1rem;
i {
color: var(--primary-color);
font-size: 0.85rem;
}
.timestamp {
margin-left: auto;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
}
}
.empty-message {
text-align: center !important;
padding: 3rem 1rem !important;
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
opacity: 0.6;
i {
font-size: 1.5rem;
}
span {
font-size: 0.875rem;
}
}
}
.info-message {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
background: rgba(234, 88, 12, 0.1);
border: 1px solid rgba(234, 88, 12, 0.3);
border-radius: 0.375rem;
color: #fbbf24;
i {
color: #f59e0b;
font-size: 1.125rem;
margin-top: 0.125rem;
flex-shrink: 0;
}
p {
margin: 0;
line-height: 1.5;
font-size: 1rem;
}
}

View File

@@ -1,86 +0,0 @@
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 '../../../websocket/model/duplicate-file-notification.model';
import {DuplicateFileService} from '../../../websocket/duplicate-file.service';
@Component({
selector: 'app-duplicate-files-dialog',
standalone: true,
imports: [CommonModule, TableModule, ButtonModule, TooltipModule, PaginatorModule],
templateUrl: './duplicate-files-dialog.component.html',
styleUrls: ['./duplicate-files-dialog.component.scss']
})
export class DuplicateFilesDialogComponent implements OnInit {
duplicateFiles$: Observable<{ hash: string; files: DuplicateFileNotification[] }[]>;
totalRecords = 0;
first = 0;
rows = 15;
constructor(
public ref: DynamicDialogRef,
public config: DynamicDialogConfig,
private duplicateFileService: DuplicateFileService
) {
this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe(
map((files: DuplicateFileNotification[] | null) => {
if (!files) return [];
// Group files by hash
const groupedFiles = files.reduce((acc, file) => {
if (!acc[file.hash]) {
acc[file.hash] = [];
}
acc[file.hash].push(file);
return acc;
}, {} as { [hash: string]: DuplicateFileNotification[] });
const groups = Object.entries(groupedFiles).map(([hash, files]) => ({
hash,
files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
}));
this.totalRecords = groups.length;
return groups.slice(this.first, this.first + this.rows);
})
);
}
ngOnInit() {}
onPageChange(event: any) {
this.first = event.first;
this.rows = event.rows;
this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe(
map((files: DuplicateFileNotification[] | null) => {
if (!files) return [];
const groupedFiles = files.reduce((acc, file) => {
if (!acc[file.hash]) {
acc[file.hash] = [];
}
acc[file.hash].push(file);
return acc;
}, {} as { [hash: string]: DuplicateFileNotification[] });
const groups = Object.entries(groupedFiles).map(([hash, files]) => ({
hash,
files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
}));
this.totalRecords = groups.length;
return groups.slice(this.first, this.first + this.rows);
})
);
}
acknowledgeAndClose() {
this.duplicateFileService.clearDuplicateFiles();
this.ref.close();
}
}

View File

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

View File

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

View File

@@ -1,80 +0,0 @@
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 '../../websocket/duplicate-file.service';
import {DuplicateFileNotification} from '../../websocket/model/duplicate-file-notification.model';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DuplicateFilesDialogComponent} from './duplicate-files-dialog/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 | null;
duplicateFiles$ = this.duplicateFileService.duplicateFiles$;
duplicateFilesCount$: Observable<number> = this.duplicateFiles$.pipe(
map(files => files?.length || 0)
);
constructor(
private dialogService: DialogService
) {
}
openDialog() {
this.ref = this.dialogService.open(DuplicateFilesDialogComponent, {
header: 'Duplicate Files Detected',
width: '80dvw',
height: '75dvh',
contentStyle: {overflow: 'hidden'},
maximizable: true,
modal: true,
data: {
duplicateFiles$: this.duplicateFiles$
}
});
this.ref?.onClose.subscribe((result: any) => {
if (result) {
// Handle any result from dialog if needed
}
});
}
closeDialog() {
this.displayDialog = false;
}
clearAllDuplicates() {
this.duplicateFileService.clearDuplicateFiles();
this.closeDialog();
}
removeDuplicate(file: DuplicateFileNotification) {
this.duplicateFileService.removeDuplicateFile(file.fullPath, file.libraryId);
}
formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString();
}
ngOnDestroy() {
if (this.ref) {
this.ref.close();
}
}
}

View File

@@ -1,8 +1,5 @@
<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 (hasMetadataTasks$ | async) {
<app-metadata-progress-widget/>
}

View File

@@ -4,8 +4,6 @@ import {MetadataProgressService} from '../../service/metadata-progress-service';
import {map} from 'rxjs/operators';
import {AsyncPipe} from '@angular/common';
import {BookdropFileService} from '../../../features/bookdrop/service/bookdrop-file.service';
import {DuplicateFilesNotificationComponent} from '../duplicate-files-notification/duplicate-files-notification.component';
import {DuplicateFileService} from '../../websocket/duplicate-file.service';
import {BookdropFilesWidgetComponent} from '../../../features/bookdrop/component/bookdrop-files-widget/bookdrop-files-widget.component';
import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metadata-progress-widget-component';
@@ -15,8 +13,7 @@ import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metad
LiveNotificationBoxComponent,
MetadataProgressWidgetComponent,
AsyncPipe,
BookdropFilesWidgetComponent,
DuplicateFilesNotificationComponent
BookdropFilesWidgetComponent
],
templateUrl: './unified-notification-popover-component.html',
standalone: true,
@@ -25,15 +22,10 @@ import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metad
export class UnifiedNotificationBoxComponent {
metadataProgressService = inject(MetadataProgressService);
bookdropFileService = inject(BookdropFileService);
duplicateFileService = inject(DuplicateFileService);
hasMetadataTasks$ = this.metadataProgressService.activeTasks$.pipe(
map(tasks => Object.keys(tasks).length > 0)
);
hasPendingBookdropFiles$ = this.bookdropFileService.hasPendingFiles$;
hasDuplicateFiles$ = this.duplicateFileService.duplicateFiles$.pipe(
map(files => files && files.length > 0)
);
}

View File

@@ -22,7 +22,6 @@ import {Subject} from 'rxjs';
import {MetadataBatchProgressNotification} from '../../../model/metadata-batch-progress.model';
import {BookdropFileService} from '../../../../features/bookdrop/service/bookdrop-file.service';
import {DialogLauncherService} from '../../../services/dialog-launcher.service';
import {DuplicateFileService} from '../../../websocket/duplicate-file.service';
import {UnifiedNotificationBoxComponent} from '../../../components/unified-notification-popover/unified-notification-popover-component';
import {Severity, LogNotification} from '../../../websocket/model/log-notification.model';
@@ -63,14 +62,12 @@ 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;
private latestNotificationSeverity?: Severity;
constructor(
@@ -82,12 +79,10 @@ export class AppTopBarComponent implements OnDestroy {
protected userService: UserService,
private metadataProgressService: MetadataProgressService,
private bookdropFileService: BookdropFileService,
private dialogLauncher: DialogLauncherService,
private duplicateFileService: DuplicateFileService
private dialogLauncher: DialogLauncherService
) {
this.subscribeToMetadataProgress();
this.subscribeToNotifications();
this.subscribeToDuplicateFiles();
this.metadataProgressService.activeTasks$
.pipe(takeUntil(this.destroy$))
@@ -106,15 +101,6 @@ 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 {
@@ -182,16 +168,6 @@ 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);
@@ -203,8 +179,7 @@ export class AppTopBarComponent implements OnDestroy {
private updateCompletedTaskCount() {
const completedMetadataTasks = Object.values(this.latestTasks).length;
const bookdropFileTaskCount = this.latestHasPendingFiles ? 1 : 0;
const duplicateFileTaskCount = this.latestHasDuplicateFiles ? 1 : 0;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount + duplicateFileTaskCount;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount;
}
private updateTaskVisibility(tasks: { [taskId: string]: MetadataBatchProgressNotification }) {
@@ -215,11 +190,6 @@ 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 {
@@ -243,7 +213,7 @@ export class AppTopBarComponent implements OnDestroy {
return 'orange';
}
}
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles)
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles)
return 'limegreen';
return 'inherit';
}
@@ -254,7 +224,7 @@ export class AppTopBarComponent implements OnDestroy {
get shouldShowNotificationBadge(): boolean {
return (
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) &&
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles) &&
!this.progressHighlight &&
!this.showPulse
);

View File

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

View File

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