diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index a116d1ca6..5be780b99 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -94,6 +94,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.assertj:assertj-core:3.27.6' testImplementation "org.mockito:mockito-inline:5.2.0" + testRuntimeOnly 'com.h2database:h2' } hibernate { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AdditionalFileController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AdditionalFileController.java index dfe91f443..41a9979c8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AdditionalFileController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AdditionalFileController.java @@ -1,8 +1,8 @@ package com.adityachandel.booklore.controller; import com.adityachandel.booklore.config.security.annotation.CheckBookAccess; -import com.adityachandel.booklore.model.dto.AdditionalFile; -import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.model.dto.BookFile; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.service.file.AdditionalFileService; import com.adityachandel.booklore.service.upload.FileUploadService; import lombok.AllArgsConstructor; @@ -25,29 +25,30 @@ public class AdditionalFileController { @GetMapping @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity> getAdditionalFiles(@PathVariable Long bookId) { - List files = additionalFileService.getAdditionalFilesByBookId(bookId); + public ResponseEntity> getAdditionalFiles(@PathVariable Long bookId) { + List files = additionalFileService.getAdditionalFilesByBookId(bookId); return ResponseEntity.ok(files); } - @GetMapping(params = "type") + @GetMapping(params = "isBook") @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity> getAdditionalFilesByType( + public ResponseEntity> getFilesByIsBook( @PathVariable Long bookId, - @RequestParam AdditionalFileType type) { - List files = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); + @RequestParam boolean isBook) { + List files = additionalFileService.getAdditionalFilesByBookIdAndIsBook(bookId, isBook); return ResponseEntity.ok(files); } @PostMapping(consumes = "multipart/form-data") @CheckBookAccess(bookIdParam = "bookId") @PreAuthorize("@securityUtil.canUpload() or @securityUtil.isAdmin()") - public ResponseEntity uploadAdditionalFile( + public ResponseEntity uploadAdditionalFile( @PathVariable Long bookId, @RequestParam("file") MultipartFile file, - @RequestParam AdditionalFileType additionalFileType, + @RequestParam boolean isBook, + @RequestParam(required = false) BookFileType bookType, @RequestParam(required = false) String description) { - AdditionalFile additionalFile = fileUploadService.uploadAdditionalFile(bookId, file, additionalFileType, description); + BookFile additionalFile = fileUploadService.uploadAdditionalFile(bookId, file, isBook, bookType, description); return ResponseEntity.ok(additionalFile); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/AdditionalFileMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/AdditionalFileMapper.java index 2c68fd477..f84069606 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/AdditionalFileMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/AdditionalFileMapper.java @@ -1,7 +1,7 @@ package com.adityachandel.booklore.mapper; -import com.adityachandel.booklore.model.dto.AdditionalFile; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.dto.BookFile; +import com.adityachandel.booklore.model.entity.BookFileEntity; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; @@ -13,12 +13,13 @@ public interface AdditionalFileMapper { @Mapping(source = "book.id", target = "bookId") @Mapping(source = ".", target = "filePath", qualifiedByName = "mapFilePath") - AdditionalFile toAdditionalFile(BookAdditionalFileEntity entity); + @Mapping(source = "bookFormat", target = "isBook") + BookFile toAdditionalFile(BookFileEntity entity); - List toAdditionalFiles(List entities); + List toAdditionalFiles(List entities); @Named("mapFilePath") - default String mapFilePath(BookAdditionalFileEntity entity) { + default String mapFilePath(BookFileEntity entity) { if (entity == null) return null; try { return entity.getFullFilePath().toString(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java index c17fa86c6..db3057dea 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java @@ -1,10 +1,10 @@ package com.adityachandel.booklore.mapper; -import com.adityachandel.booklore.model.dto.AdditionalFile; +import com.adityachandel.booklore.model.dto.BookFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.LibraryPath; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.entity.*; -import com.adityachandel.booklore.model.enums.AdditionalFileType; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -23,8 +23,9 @@ public interface BookMapper { @Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly") @Mapping(source = "metadata", target = "metadata") @Mapping(source = "shelves", target = "shelves") - @Mapping(source = "additionalFiles", target = "alternativeFormats", qualifiedByName = "mapAlternativeFormats") - @Mapping(source = "additionalFiles", target = "supplementaryFiles", qualifiedByName = "mapSupplementaryFiles") + @Mapping(source = "bookFiles", target = "bookType", qualifiedByName = "mapPrimaryBookType") + @Mapping(source = "bookFiles", target = "alternativeFormats", qualifiedByName = "mapAlternativeFormats") + @Mapping(source = "bookFiles", target = "supplementaryFiles", qualifiedByName = "mapSupplementaryFiles") Book toBook(BookEntity bookEntity); @Mapping(source = "library.id", target = "libraryId") @@ -32,8 +33,9 @@ public interface BookMapper { @Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly") @Mapping(source = "metadata", target = "metadata") @Mapping(source = "shelves", target = "shelves") - @Mapping(source = "additionalFiles", target = "alternativeFormats", qualifiedByName = "mapAlternativeFormats") - @Mapping(source = "additionalFiles", target = "supplementaryFiles", qualifiedByName = "mapSupplementaryFiles") + @Mapping(source = "bookFiles", target = "bookType", qualifiedByName = "mapPrimaryBookType") + @Mapping(source = "bookFiles", target = "alternativeFormats", qualifiedByName = "mapAlternativeFormats") + @Mapping(source = "bookFiles", target = "supplementaryFiles", qualifiedByName = "mapSupplementaryFiles") Book toBookWithDescription(BookEntity bookEntity, @Context boolean includeDescription); default Set mapAuthors(Set authors) { @@ -72,33 +74,54 @@ public interface BookMapper { .build(); } + @Named("mapPrimaryBookType") + default BookFileType mapPrimaryBookType(List bookFiles) { + BookFileEntity primary = getPrimaryBookFile(bookFiles); + return primary == null ? null : primary.getBookType(); + } + @Named("mapAlternativeFormats") - default List mapAlternativeFormats(List additionalFiles) { - if (additionalFiles == null) return null; - return additionalFiles.stream() - .filter(af -> AdditionalFileType.ALTERNATIVE_FORMAT.equals(af.getAdditionalFileType())) - .map(this::toAdditionalFile) + default List mapAlternativeFormats(List bookFiles) { + if (bookFiles == null) return null; + return bookFiles.stream() + .filter(bf -> bf.isBook()) + .filter(bf -> !bf.equals(getPrimaryBookFile(bookFiles))) + .map(this::toBookFile) .toList(); } @Named("mapSupplementaryFiles") - default List mapSupplementaryFiles(List additionalFiles) { - if (additionalFiles == null) return null; - return additionalFiles.stream() - .filter(af -> AdditionalFileType.SUPPLEMENTARY.equals(af.getAdditionalFileType())) - .map(this::toAdditionalFile) + default List mapSupplementaryFiles(List bookFiles) { + if (bookFiles == null) + return null; + return bookFiles.stream() + .filter(bf -> !bf.isBook()) + .map(this::toBookFile) .toList(); } - default AdditionalFile toAdditionalFile(BookAdditionalFileEntity entity) { + /* + * TODO: evolve the logic so that the user can select the primary book file format to be used. + * For now, we just return the first book file in the list. + */ + default BookFileEntity getPrimaryBookFile(List bookFiles) { + if (bookFiles == null || bookFiles.isEmpty()) return null; + return bookFiles.stream() + .filter(bf -> bf.isBook()) + .findFirst() + .orElse(null); + } + + default BookFile toBookFile(BookFileEntity entity) { if (entity == null) return null; - return AdditionalFile.builder() + return BookFile.builder() .id(entity.getId()) .bookId(entity.getBook().getId()) .fileName(entity.getFileName()) .filePath(entity.getFullFilePath().toString()) .fileSubPath(entity.getFileSubPath()) - .additionalFileType(entity.getAdditionalFileType()) + .isBook(entity.isBook()) + .bookType(entity.getBookType()) .fileSizeKb(entity.getFileSizeKb()) .description(entity.getDescription()) .addedOn(entity.getAddedOn()) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java index 3ad36790c..94f5e44e9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java @@ -5,11 +5,13 @@ import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.LibraryPath; import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.BookFileType; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; import org.mapstruct.ReportingPolicy; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -19,6 +21,7 @@ public interface BookMapperV2 { @Mapping(source = "library.id", target = "libraryId") @Mapping(source = "library.name", target = "libraryName") @Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly") + @Mapping(source = "bookFiles", target = "bookType", qualifiedByName = "mapPrimaryBookType") @Mapping(target = "metadata", qualifiedByName = "mapMetadata") Book toDTO(BookEntity bookEntity); @@ -53,6 +56,16 @@ public interface BookMapperV2 { tags.stream().map(TagEntity::getName).collect(Collectors.toSet()); } + @Named("mapPrimaryBookType") + default BookFileType mapPrimaryBookType(List bookFiles) { + if (bookFiles == null || bookFiles.isEmpty()) return null; + return bookFiles.stream() + .filter(BookFileEntity::isBook) + .map(BookFileEntity::getBookType) + .findFirst() + .orElse(null); + } + @Named("mapLibraryPathIdOnly") default LibraryPath mapLibraryPathIdOnly(LibraryPathEntity entity) { if (entity == null) return null; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java index 7bcf17843..e0f68ca9c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java @@ -38,6 +38,6 @@ public class Book { private String readStatus; private Instant dateFinished; private LibraryPath libraryPath; - private List alternativeFormats; - private List supplementaryFiles; + private List alternativeFormats; + private List supplementaryFiles; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/AdditionalFile.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookFile.java similarity index 75% rename from booklore-api/src/main/java/com/adityachandel/booklore/model/dto/AdditionalFile.java rename to booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookFile.java index dc6dcd7dc..ceb68e72e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/AdditionalFile.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookFile.java @@ -1,6 +1,6 @@ package com.adityachandel.booklore.model.dto; -import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.model.enums.BookFileType; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Data; @@ -10,13 +10,14 @@ import java.time.Instant; @Builder @Data @JsonInclude(JsonInclude.Include.NON_NULL) -public class AdditionalFile { +public class BookFile { private Long id; private Long bookId; private String fileName; private String filePath; private String fileSubPath; - private AdditionalFileType additionalFileType; + private boolean isBook; + private BookFileType bookType; private Long fileSizeKb; private String description; private Instant addedOn; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java index 6b9542358..bd3627fa3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java @@ -10,8 +10,10 @@ import lombok.*; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; @Entity @Getter @@ -26,22 +28,6 @@ public class BookEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "file_name", length = 1000) - private String fileName; - - @Column(name = "file_sub_path") - private String fileSubPath; - - @Column(name = "book_type") - private BookFileType bookType; - - @Column(name = "archive_type") - @Enumerated(EnumType.STRING) - private ArchiveUtils.ArchiveType archiveType; - - @Column(name = "file_size_kb") - private Long fileSizeKb; - @Column(name = "metadata_match_score") private Float metadataMatchScore; @@ -65,12 +51,6 @@ public class BookEntity { @Column(name = "added_on") private Instant addedOn; - @Column(name = "initial_hash", length = 128, updatable = false) - private String initialHash; - - @Column(name = "current_hash", length = 128) - private String currentHash; - @Column(name = "book_cover_hash", length = 20) private String bookCoverHash; @@ -94,16 +74,41 @@ public class BookEntity { private Set similarBooksJson; @OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List additionalFiles; + @OrderBy("id ASC") + @Builder.Default + private List bookFiles = new ArrayList<>(); @OneToMany(mappedBy = "book", fetch = FetchType.LAZY) private List userBookProgress; public Path getFullFilePath() { - if (libraryPath == null || libraryPath.getPath() == null || fileSubPath == null || fileName == null) { - throw new IllegalStateException("Cannot construct file path: missing library path, file subpath, or file name"); + BookFileEntity primaryBookFile = getPrimaryBookFile(); + if (libraryPath == null || libraryPath.getPath() == null || primaryBookFile.getFileSubPath() == null || primaryBookFile.getFileName() == null) { + throw new IllegalStateException( + "Cannot construct file path: missing library path, file subpath, or file name"); } - return Paths.get(libraryPath.getPath(), fileSubPath, fileName); + return Paths.get(libraryPath.getPath(), primaryBookFile.getFileSubPath(), primaryBookFile.getFileName()); + } + + public List getFullFilePaths() { + if (libraryPath == null || libraryPath.getPath() == null || bookFiles == null || bookFiles.isEmpty()) { + throw new IllegalStateException( + "Cannot construct file path: missing library path, file subpath, or file name"); + } + return bookFiles.stream() + .map(bookFile -> Paths.get(libraryPath.getPath(), bookFile.getFileSubPath(), bookFile.getFileName())) + .collect(Collectors.toList()); + } + + // TODO: Add support for specifying the preferred format + public BookFileEntity getPrimaryBookFile() { + if (bookFiles == null) { + bookFiles = new ArrayList<>(); + } + if (bookFiles.isEmpty()) { + throw new IllegalStateException("Book file not found"); + } + return bookFiles.getFirst(); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookAdditionalFileEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookFileEntity.java similarity index 75% rename from booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookAdditionalFileEntity.java rename to booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookFileEntity.java index 3d0659a09..d8457ad9d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookAdditionalFileEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookFileEntity.java @@ -1,6 +1,6 @@ package com.adityachandel.booklore.model.entity; - -import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.util.ArchiveUtils; +import com.adityachandel.booklore.model.enums.BookFileType; import jakarta.persistence.*; import lombok.*; @@ -14,8 +14,8 @@ import java.time.Instant; @Builder @NoArgsConstructor @AllArgsConstructor -@Table(name = "book_additional_file") -public class BookAdditionalFileEntity { +@Table(name = "book_file") +public class BookFileEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -31,9 +31,16 @@ public class BookAdditionalFileEntity { @Column(name = "file_sub_path", length = 512, nullable = false) private String fileSubPath; + @Column(name = "is_book", nullable = false) + private boolean isBookFormat; + @Enumerated(EnumType.STRING) - @Column(name = "additional_file_type", nullable = false) - private AdditionalFileType additionalFileType; + @Column(name = "book_type", nullable = false) + private BookFileType bookType; + + @Column(name = "archive_type") + @Enumerated(EnumType.STRING) + private ArchiveUtils.ArchiveType archiveType; @Column(name = "file_size_kb") private Long fileSizeKb; @@ -53,6 +60,10 @@ public class BookAdditionalFileEntity { @Column(name = "added_on") private Instant addedOn; + public boolean isBook() { + return isBookFormat; + } + public Path getFullFilePath() { if (book == null || book.getLibraryPath() == null || book.getLibraryPath().getPath() == null || fileSubPath == null || fileName == null) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookAdditionalFileRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookAdditionalFileRepository.java index 5c4258cb8..afc235711 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookAdditionalFileRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookAdditionalFileRepository.java @@ -1,8 +1,9 @@ package com.adityachandel.booklore.repository; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.model.entity.BookFileEntity; +import com.adityachandel.booklore.model.enums.BookFileType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -11,14 +12,16 @@ import java.util.List; import java.util.Optional; @Repository -public interface BookAdditionalFileRepository extends JpaRepository { +public interface BookAdditionalFileRepository extends JpaRepository { - List findByBookId(Long bookId); + List findByBookId(Long bookId); - List findByBookIdAndAdditionalFileType(Long bookId, AdditionalFileType additionalFileType); + List findByBookIdAndIsBookFormat(Long bookId, boolean isBookFormat); + + List findByBookIdAndBookType(Long bookId, BookFileType bookType); /** - * Finds a {@link BookAdditionalFileEntity} by its alternative format current hash. + * Finds a {@link BookFileEntity} by its alternative format current hash. *

* This method queries against the {@code alt_format_current_hash} virtual column, which is indexed * and only contains values for files where the {@code additional_file_type} is 'ALTERNATIVE_FORMAT'. @@ -27,18 +30,32 @@ public interface BookAdditionalFileRepository extends JpaRepository findByAltFormatCurrentHash(String altFormatCurrentHash); + Optional findByAltFormatCurrentHash(String altFormatCurrentHash); - @Query("SELECT af FROM BookAdditionalFileEntity af WHERE af.book.libraryPath.id = :libraryPathId AND af.fileSubPath = :fileSubPath AND af.fileName = :fileName") - Optional findByLibraryPath_IdAndFileSubPathAndFileName(@Param("libraryPathId") Long libraryPathId, + @Query("SELECT bf FROM BookFileEntity bf WHERE bf.book.libraryPath.id = :libraryPathId AND bf.fileSubPath = :fileSubPath AND bf.fileName = :fileName") + Optional findByLibraryPath_IdAndFileSubPathAndFileName(@Param("libraryPathId") Long libraryPathId, @Param("fileSubPath") String fileSubPath, @Param("fileName") String fileName); - List findByAdditionalFileType(AdditionalFileType additionalFileType); + List findByIsBookFormat(boolean isBookFormat); - @Query("SELECT COUNT(af) FROM BookAdditionalFileEntity af WHERE af.book.id = :bookId AND af.additionalFileType = :additionalFileType") - long countByBookIdAndAdditionalFileType(@Param("bookId") Long bookId, @Param("additionalFileType") AdditionalFileType additionalFileType); + List findByBookType(BookFileType bookType); - @Query("SELECT af FROM BookAdditionalFileEntity af WHERE af.book.library.id = :libraryId") - List findByLibraryId(@Param("libraryId") Long libraryId); + @Query("SELECT COUNT(bf) FROM BookFileEntity bf WHERE bf.book.id = :bookId AND bf.isBookFormat = :isBookFormat") + long countByBookIdAndIsBookFormat(@Param("bookId") Long bookId, @Param("isBookFormat") boolean isBookFormat); + + @Query("SELECT bf FROM BookFileEntity bf WHERE bf.book.library.id = :libraryId") + List findByLibraryId(@Param("libraryId") Long libraryId); + + @Modifying + @Query(""" + UPDATE BookFileEntity bf SET + bf.fileName = :fileName, + bf.fileSubPath = :fileSubPath + WHERE bf.id = :bookFileId + """) + void updateFileNameAndSubPath( + @Param("bookFileId") Long bookFileId, + @Param("fileName") String fileName, + @Param("fileSubPath") String fileSubPath); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java index 4b93afa34..928cd4cda 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java @@ -23,7 +23,7 @@ public interface BookOpdsRepository extends JpaRepository, Jpa @Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC") Page findBookIds(Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"}) + @EntityGraph(attributePaths = {"metadata", "bookFiles", "shelves"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :ids AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIds(@Param("ids") Collection ids); @@ -43,7 +43,7 @@ public interface BookOpdsRepository extends JpaRepository, Jpa @Query("SELECT b.id FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC") Page findBookIdsByLibraryIds(@Param("libraryIds") Collection libraryIds, Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"}) + @EntityGraph(attributePaths = {"metadata", "bookFiles", "shelves"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :ids AND b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIdsAndLibraryIds(@Param("ids") Collection ids, @Param("libraryIds") Collection libraryIds); @@ -63,7 +63,7 @@ public interface BookOpdsRepository extends JpaRepository, Jpa @Query("SELECT DISTINCT b.id FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC") Page findBookIdsByShelfId(@Param("shelfId") Long shelfId, Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"}) + @EntityGraph(attributePaths = {"metadata", "bookFiles", "shelves"}) @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE b.id IN :ids AND s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIdsAndShelfId(@Param("ids") Collection ids, @Param("shelfId") Long shelfId); @@ -81,7 +81,7 @@ public interface BookOpdsRepository extends JpaRepository, Jpa """) Page findBookIdsByMetadataSearch(@Param("text") String text, Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "additionalFiles", "shelves"}) + @EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "bookFiles", "shelves"}) @Query("SELECT DISTINCT b FROM BookEntity b WHERE b.id IN :ids AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithFullMetadataByIds(@Param("ids") Collection ids); @@ -101,7 +101,7 @@ public interface BookOpdsRepository extends JpaRepository, Jpa """) Page findBookIdsByMetadataSearchAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds, Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "additionalFiles", "shelves"}) + @EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "bookFiles", "shelves"}) @Query("SELECT DISTINCT b FROM BookEntity b WHERE b.id IN :ids AND b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithFullMetadataByIdsAndLibraryIds(@Param("ids") Collection ids, @Param("libraryIds") Collection libraryIds); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index a360e48e4..1fc0bf0af 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -20,16 +20,22 @@ import java.util.Set; public interface BookRepository extends JpaRepository, JpaSpecificationExecutor { Optional findBookByIdAndLibraryId(long id, long libraryId); - Optional findByCurrentHash(String currentHash); + @EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "bookFiles" }) + @Query("SELECT b FROM BookEntity b LEFT JOIN FETCH b.bookFiles bf WHERE b.id = :id AND (b.deleted IS NULL OR b.deleted = false)") + Optional findByIdWithBookFiles(@Param("id") Long id); + + @Query("SELECT b FROM BookEntity b JOIN b.bookFiles bf WHERE bf.currentHash = :currentHash AND bf.isBookFormat = true AND (b.deleted IS NULL OR b.deleted = false)") + Optional findByCurrentHash(@Param("currentHash") String currentHash); Optional findByBookCoverHash(String bookCoverHash); @Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") Set findBookIdsByLibraryId(@Param("libraryId") long libraryId); - List findAllByLibraryPathIdAndFileSubPathStartingWith(Long libraryPathId, String fileSubPathPrefix); + @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.bookFiles bf WHERE b.libraryPath.id = :libraryPathId AND bf.fileSubPath LIKE CONCAT(:fileSubPathPrefix, '%') AND bf.isBookFormat = true AND (b.deleted IS NULL OR b.deleted = false)") + List findAllByLibraryPathIdAndFileSubPathStartingWith(@Param("libraryPathId") Long libraryPathId, @Param("fileSubPathPrefix") String fileSubPathPrefix); - @Query("SELECT b FROM BookEntity b WHERE b.libraryPath.id = :libraryPathId AND b.fileSubPath = :fileSubPath AND b.fileName = :fileName AND (b.deleted IS NULL OR b.deleted = false)") + @Query("SELECT b FROM BookEntity b JOIN b.bookFiles bf WHERE b.libraryPath.id = :libraryPathId AND bf.fileSubPath = :fileSubPath AND bf.fileName = :fileName AND bf.isBookFormat = true AND (b.deleted IS NULL OR b.deleted = false)") Optional findByLibraryPath_IdAndFileSubPathAndFileName(@Param("libraryPathId") Long libraryPathId, @Param("fileSubPath") String fileSubPath, @Param("fileName") String fileName); @@ -37,32 +43,32 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b.id FROM BookEntity b WHERE b.libraryPath.id IN :libraryPathIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllBookIdsByLibraryPathIdIn(@Param("libraryPathIds") Collection libraryPathIds); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadata(); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIds(@Param("bookIds") Set bookIds); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") List findWithMetadataByIdsWithPagination(@Param("bookIds") Set bookIds, Pageable pageable); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection libraryIds); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"}) @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId); - @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) - @Query("SELECT b FROM BookEntity b WHERE b.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)") + @EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "bookFiles" }) + @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.bookFiles bf WHERE bf.isBookFormat = true AND bf.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByFileSizeKbIsNull(); @Query(""" @@ -105,31 +111,17 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT COUNT(b) FROM BookEntity b WHERE b.deleted = TRUE") long countAllSoftDeleted(); - @Modifying - @Query(""" - UPDATE BookEntity b - SET b.fileSubPath = :fileSubPath, - b.fileName = :fileName, - b.library.id = :libraryId, - b.libraryPath = :libraryPath - WHERE b.id = :bookId - """) - void updateFileAndLibrary( - @Param("bookId") Long bookId, - @Param("fileSubPath") String fileSubPath, - @Param("fileName") String fileName, - @Param("libraryId") Long libraryId, - @Param("libraryPath") LibraryPathEntity libraryPath); - @Query(value = """ - SELECT * - FROM book - WHERE library_id = :libraryId - AND library_path_id = :libraryPathId - AND file_sub_path = :fileSubPath - AND file_name = :fileName - LIMIT 1 - """, nativeQuery = true) + SELECT b.* + FROM book b + JOIN book_file bf ON bf.book_id = b.id + WHERE b.library_id = :libraryId + AND b.library_path_id = :libraryPathId + AND bf.file_sub_path = :fileSubPath + AND bf.file_name = :fileName + AND bf.is_book = true + LIMIT 1 + """, nativeQuery = true) Optional findByLibraryIdAndLibraryPathIdAndFileSubPathAndFileName( @Param("libraryId") Long libraryId, @Param("libraryPathId") Long libraryPathId, @@ -139,7 +131,13 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT COUNT(b.id) FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") long countByIdIn(@Param("bookIds") List bookIds); - @Query("SELECT COUNT(b) FROM BookEntity b WHERE b.bookType = :type AND (b.deleted IS NULL OR b.deleted = false)") + @Query(""" + SELECT COUNT(DISTINCT b) FROM BookEntity b + JOIN b.bookFiles bf + WHERE bf.isBookFormat = true + AND bf.bookType = :type + AND (b.deleted IS NULL OR b.deleted = false) + """) long countByBookType(@Param("type") BookFileType type); @Query("SELECT COUNT(b) FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") @@ -147,4 +145,16 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b.id as id, m.coverUpdatedOn as coverUpdatedOn FROM BookEntity b LEFT JOIN b.metadata m WHERE b.id IN :bookIds") List findCoverUpdateInfoByIds(@Param("bookIds") Collection bookIds); + + @Modifying + @Query(""" + UPDATE BookEntity b SET + b.library.id = :libraryId, + b.libraryPath = :libraryPath + WHERE b.id = :bookId + """) + void updateLibrary( + @Param("bookId") Long bookId, + @Param("libraryId") Long libraryId, + @Param("libraryPath") LibraryPathEntity libraryPath); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookCreatorService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookCreatorService.java index 9e46f8ad5..d7e21c170 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookCreatorService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookCreatorService.java @@ -35,10 +35,11 @@ public class BookCreatorService { String newHash = FileFingerprint.generateHash(libraryFile.getFullPath()); long fileSizeKb = FileUtils.getFileSizeInKb(libraryFile.getFullPath()); BookEntity existingBook = existingBookOpt.get(); - existingBook.setCurrentHash(newHash); - existingBook.setInitialHash(newHash); + BookFileEntity primaryFile = existingBook.getPrimaryBookFile(); + primaryFile.setCurrentHash(newHash); + primaryFile.setInitialHash(newHash); + primaryFile.setFileSizeKb(fileSizeKb); existingBook.setDeleted(false); - existingBook.setFileSizeKb(fileSizeKb); return existingBook; } @@ -47,12 +48,20 @@ public class BookCreatorService { BookEntity bookEntity = BookEntity.builder() .library(libraryFile.getLibraryEntity()) .libraryPath(libraryFile.getLibraryPathEntity()) + .addedOn(Instant.now()) + .bookFiles(new ArrayList<>()) + .build(); + + BookFileEntity bookFileEntity = BookFileEntity.builder() + .book(bookEntity) .fileName(libraryFile.getFileName()) .fileSubPath(libraryFile.getFileSubPath()) + .isBookFormat(true) .bookType(bookFileType) .fileSizeKb(fileSizeKb) .addedOn(Instant.now()) .build(); + bookEntity.getBookFiles().add(bookFileEntity); BookMetadataEntity metadata = BookMetadataEntity.builder() .book(bookEntity) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java index 9a0ffda45..d3b3d36e2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java @@ -80,8 +80,9 @@ public class BookDownloadService { public void downloadKoboBook(Long bookId, HttpServletResponse response) { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - boolean isEpub = bookEntity.getBookType() == BookFileType.EPUB; - boolean isCbx = bookEntity.getBookType() == BookFileType.CBX; + var primaryFile = bookEntity.getPrimaryBookFile(); + boolean isEpub = primaryFile.getBookType() == BookFileType.EPUB; + boolean isCbx = primaryFile.getBookType() == BookFileType.CBX; if (!isEpub && !isCbx) { throw ApiError.GENERIC_BAD_REQUEST.createException("The requested book is not an EPUB or CBX file."); @@ -92,8 +93,8 @@ public class BookDownloadService { throw ApiError.GENERIC_BAD_REQUEST.createException("Kobo settings not found."); } - boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024; - boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024; + boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && primaryFile.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024; + boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && primaryFile.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024; int compressionPercentage = koboSettings.getConversionImageCompressionPercentage(); Path tempDir = null; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java index 11b3f797a..012cabe6e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java @@ -9,7 +9,13 @@ import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.EpubViewerPreferencesEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity; import com.adityachandel.booklore.model.entity.UserBookProgressEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.ReadStatus; @@ -20,10 +26,8 @@ import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.core.io.*; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -37,6 +41,7 @@ import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -146,7 +151,7 @@ public class BookService { public Book getBook(long bookId, boolean withDescription) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); UserBookProgressEntity userProgress = userBookProgressRepository.findByUserIdAndBookId(user.getId(), bookId).orElse(new UserBookProgressEntity()); @@ -160,30 +165,35 @@ public class BookService { .build()); } - if (bookEntity.getBookType() == BookFileType.PDF) { - book.setPdfProgress(PdfProgress.builder() - .page(userProgress.getPdfProgress()) - .percentage(userProgress.getPdfProgressPercent()) - .build()); - } - if (bookEntity.getBookType() == BookFileType.EPUB) { - book.setEpubProgress(EpubProgress.builder() - .cfi(userProgress.getEpubProgress()) - .percentage(userProgress.getEpubProgressPercent()) - .build()); - if (userProgress.getKoreaderProgressPercent() != null) { - if (book.getKoreaderProgress() == null) { - book.setKoreaderProgress(KoProgress.builder().build()); - } - book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100); + bookEntity.getBookFiles().iterator().forEachRemaining(bookFile -> { + if (bookFile.getBookType() == BookFileType.PDF) { + book.setPdfProgress(PdfProgress.builder() + .page(userProgress.getPdfProgress()) + .percentage(userProgress.getPdfProgressPercent()) + .build()); } - } - if (bookEntity.getBookType() == BookFileType.CBX) { - book.setCbxProgress(CbxProgress.builder() - .page(userProgress.getCbxProgress()) - .percentage(userProgress.getCbxProgressPercent()) - .build()); - } + + if (bookFile.getBookType() == BookFileType.EPUB) { + book.setEpubProgress(EpubProgress.builder() + .cfi(userProgress.getEpubProgress()) + .percentage(userProgress.getEpubProgressPercent()) + .build()); + if (userProgress.getKoreaderProgressPercent() != null) { + if (book.getKoreaderProgress() == null) { + book.setKoreaderProgress(KoProgress.builder().build()); + } + book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100); + } + } + + if (bookFile.getBookType() == BookFileType.CBX) { + book.setCbxProgress(CbxProgress.builder() + .page(userProgress.getCbxProgress()) + .percentage(userProgress.getCbxProgressPercent()) + .build()); + } + }); + book.setFilePath(FileUtils.getBookFullPath(bookEntity)); book.setReadStatus(userProgress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(userProgress.getReadStatus())); book.setDateFinished(userProgress.getDateFinished()); @@ -197,11 +207,13 @@ public class BookService { } public BookViewerSettings getBookViewerSetting(long bookId) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); BookLoreUser user = authenticationService.getAuthenticatedUser(); BookViewerSettings.BookViewerSettingsBuilder settingsBuilder = BookViewerSettings.builder(); - if (bookEntity.getBookType() == BookFileType.EPUB) { + BookFileEntity bookFileEntity = bookEntity.getPrimaryBookFile(); + + if (bookFileEntity.getBookType() == BookFileType.EPUB) { epubViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(epubPref -> settingsBuilder.epubSettings(EpubViewerPreferences.builder() .bookId(bookId) @@ -213,7 +225,7 @@ public class BookService { .letterSpacing(epubPref.getLetterSpacing()) .lineHeight(epubPref.getLineHeight()) .build())); - } else if (bookEntity.getBookType() == BookFileType.PDF) { + } else if (bookFileEntity.getBookType() == BookFileType.PDF) { pdfViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(pdfPref -> settingsBuilder.pdfSettings(PdfViewerPreferences.builder() .bookId(bookId) @@ -226,7 +238,7 @@ public class BookService { .pageViewMode(pdfPref.getPageViewMode()) .pageSpread(pdfPref.getPageSpread()) .build())); - } else if (bookEntity.getBookType() == BookFileType.CBX) { + } else if (bookFileEntity.getBookType() == BookFileType.CBX) { cbxViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(cbxPref -> settingsBuilder.cbxSettings(CbxViewerPreferences.builder() .bookId(bookId) @@ -312,30 +324,32 @@ public class BookService { List books = bookQueryService.findAllWithMetadataByIds(ids); List failedFileDeletions = new ArrayList<>(); for (BookEntity book : books) { - Path fullFilePath = book.getFullFilePath(); - try { - if (Files.exists(fullFilePath)) { - try { - monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent()); - } catch (Exception ex) { - log.warn("Failed to unregister monitoring for path: {}", fullFilePath.getParent(), ex); + List fullFilePaths = book.getFullFilePaths(); + for (Path fullFilePath : fullFilePaths) { + try { + if (Files.exists(fullFilePath)) { + try { + monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent()); + } catch (Exception ex) { + log.warn("Failed to unregister monitoring for path: {}", fullFilePath.getParent(), ex); + } + Files.delete(fullFilePath); + log.info("Deleted book file: {}", fullFilePath); + + Set libraryRoots = book.getLibrary().getLibraryPaths().stream() + .map(LibraryPathEntity::getPath) + .map(Paths::get) + .map(Path::normalize) + .collect(Collectors.toSet()); + + deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); } - Files.delete(fullFilePath); - log.info("Deleted book file: {}", fullFilePath); - - Set libraryRoots = book.getLibrary().getLibraryPaths().stream() - .map(LibraryPathEntity::getPath) - .map(Paths::get) - .map(Path::normalize) - .collect(Collectors.toSet()); - - deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); + } catch (IOException e) { + log.warn("Failed to delete book file: {}", fullFilePath, e); + failedFileDeletions.add(book.getId()); + } finally { + monitoringRegistrationService.registerSpecificPath(fullFilePath.getParent(), book.getLibrary().getId()); } - } catch (IOException e) { - log.warn("Failed to delete book file: {}", fullFilePath, e); - failedFileDeletions.add(book.getId()); - } finally { - monitoringRegistrationService.registerSpecificPath(fullFilePath.getParent(), book.getLibrary().getId()); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java index 3f11111e5..df15da517 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java @@ -1,32 +1,56 @@ package com.adityachandel.booklore.service.book; -import com.adityachandel.booklore.config.security.service.AuthenticationService; -import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.*; -import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; -import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse; -import com.adityachandel.booklore.model.dto.response.PersonalRatingUpdateResponse; -import com.adityachandel.booklore.model.entity.*; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.ReadStatus; -import com.adityachandel.booklore.model.enums.ResetProgressType; -import com.adityachandel.booklore.model.enums.UserPermission; -import com.adityachandel.booklore.repository.*; -import com.adityachandel.booklore.service.kobo.KoboReadingStateService; -import com.adityachandel.booklore.service.user.UserProgressService; -import com.adityachandel.booklore.util.FileUtils; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.EnumUtils; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.BookViewerSettings; +import com.adityachandel.booklore.model.dto.CbxViewerPreferences; +import com.adityachandel.booklore.model.dto.EpubViewerPreferences; +import com.adityachandel.booklore.model.dto.NewPdfViewerPreferences; +import com.adityachandel.booklore.model.dto.PdfViewerPreferences; +import com.adityachandel.booklore.model.dto.Shelf; +import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; +import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse; +import com.adityachandel.booklore.model.dto.response.PersonalRatingUpdateResponse; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.EpubViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity; +import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.ReadStatus; +import com.adityachandel.booklore.model.enums.ResetProgressType; +import com.adityachandel.booklore.model.enums.UserPermission; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.CbxViewerPreferencesRepository; +import com.adityachandel.booklore.repository.EpubViewerPreferencesRepository; +import com.adityachandel.booklore.repository.NewPdfViewerPreferencesRepository; +import com.adityachandel.booklore.repository.PdfViewerPreferencesRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import com.adityachandel.booklore.repository.UserBookProgressRepository; +import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.kobo.KoboReadingStateService; +import com.adityachandel.booklore.service.user.UserProgressService; +import com.adityachandel.booklore.util.FileUtils; + +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @AllArgsConstructor @Service @@ -47,10 +71,11 @@ public class BookUpdateService { private final KoboReadingStateService koboReadingStateService; public void updateBookViewerSetting(long bookId, BookViewerSettings bookViewerSettings) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + BookFileEntity bookFileEntity = bookEntity.getPrimaryBookFile(); BookLoreUser user = authenticationService.getAuthenticatedUser(); - if (bookEntity.getBookType() == BookFileType.PDF) { + if (bookFileEntity.getBookType() == BookFileType.PDF) { if (bookViewerSettings.getPdfSettings() != null) { PdfViewerPreferencesEntity pdfPrefs = pdfViewerPreferencesRepository .findByBookIdAndUserId(bookId, user.getId()) @@ -80,7 +105,7 @@ public class BookUpdateService { pdfPrefs.setPageViewMode(pdfSettings.getPageViewMode()); newPdfViewerPreferencesRepository.save(pdfPrefs); } - } else if (bookEntity.getBookType() == BookFileType.EPUB) { + } else if (bookFileEntity.getBookType() == BookFileType.EPUB) { EpubViewerPreferencesEntity epubPrefs = epubViewerPreferencesRepository .findByBookIdAndUserId(bookId, user.getId()) .orElseGet(() -> { @@ -101,7 +126,7 @@ public class BookUpdateService { epubPrefs.setLineHeight(epubSettings.getLineHeight()); epubViewerPreferencesRepository.save(epubPrefs); - } else if (bookEntity.getBookType() == BookFileType.CBX) { + } else if (bookFileEntity.getBookType() == BookFileType.CBX) { CbxViewerPreferencesEntity cbxPrefs = cbxViewerPreferencesRepository .findByBookIdAndUserId(bookId, user.getId()) .orElseGet(() -> { @@ -127,7 +152,7 @@ public class BookUpdateService { @Transactional public void updateReadProgress(ReadProgressRequest request) { - BookEntity book = bookRepository.findById(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); + BookEntity book = bookRepository.findByIdWithBookFiles(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); BookLoreUser user = authenticationService.getAuthenticatedUser(); @@ -143,7 +168,8 @@ public class BookUpdateService { progress.setLastReadTime(Instant.now()); Float percentage = null; - switch (book.getBookType()) { + BookFileEntity bookFileEntity = book.getPrimaryBookFile(); + switch (bookFileEntity.getBookType()) { case EPUB -> { if (request.getEpubProgress() != null) { progress.setEpubProgress(request.getEpubProgress().getCfi()); @@ -166,7 +192,7 @@ public class BookUpdateService { if (percentage != null) { progress.setReadStatus(getStatus(percentage)); - setProgressPercent(progress, book.getBookType(), percentage); + setProgressPercent(progress, bookFileEntity.getBookType(), percentage); } if (request.getDateFinished() != null) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/AdditionalFileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/AdditionalFileService.java index 1137a03be..9545a43d7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/AdditionalFileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/AdditionalFileService.java @@ -1,9 +1,8 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.mapper.AdditionalFileMapper; -import com.adityachandel.booklore.model.dto.AdditionalFile; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.enums.AdditionalFileType; +import com.adityachandel.booklore.model.dto.BookFile; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import lombok.AllArgsConstructor; @@ -36,24 +35,24 @@ public class AdditionalFileService { private final AdditionalFileMapper additionalFileMapper; private final MonitoringRegistrationService monitoringRegistrationService; - public List getAdditionalFilesByBookId(Long bookId) { - List entities = additionalFileRepository.findByBookId(bookId); + public List getAdditionalFilesByBookId(Long bookId) { + List entities = additionalFileRepository.findByBookId(bookId); return additionalFileMapper.toAdditionalFiles(entities); } - public List getAdditionalFilesByBookIdAndType(Long bookId, AdditionalFileType type) { - List entities = additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type); + public List getAdditionalFilesByBookIdAndIsBook(Long bookId, boolean isBook) { + List entities = additionalFileRepository.findByBookIdAndIsBookFormat(bookId, isBook); return additionalFileMapper.toAdditionalFiles(entities); } @Transactional public void deleteAdditionalFile(Long fileId) { - Optional fileOpt = additionalFileRepository.findById(fileId); + Optional fileOpt = additionalFileRepository.findById(fileId); if (fileOpt.isEmpty()) { throw new IllegalArgumentException("Additional file not found with id: " + fileId); } - BookAdditionalFileEntity file = fileOpt.get(); + BookFileEntity file = fileOpt.get(); try { monitoringRegistrationService.unregisterSpecificPath(file.getFullFilePath().getParent()); @@ -69,12 +68,12 @@ public class AdditionalFileService { } public ResponseEntity downloadAdditionalFile(Long fileId) throws IOException { - Optional fileOpt = additionalFileRepository.findById(fileId); + Optional fileOpt = additionalFileRepository.findById(fileId); if (fileOpt.isEmpty()) { return ResponseEntity.notFound().build(); } - BookAdditionalFileEntity file = fileOpt.get(); + BookFileEntity file = fileOpt.get(); Path filePath = file.getFullFilePath(); if (!Files.exists(filePath)) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveHelper.java index bb3cc2d9a..d9f8f22b1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveHelper.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -159,6 +160,15 @@ public class FileMoveHelper { return Paths.get(path, newRelativePathStr); } + public Path generateNewFilePath(BookEntity book, BookFileEntity bookFile, LibraryPathEntity libraryPathEntity, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book, bookFile, pattern); + if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { + newRelativePathStr = newRelativePathStr.substring(1); + } + String path = libraryPathEntity.getPath(); + return Paths.get(path, newRelativePathStr); + } + public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) { Path dir = currentDir.toAbsolutePath().normalize(); Set normalizedRoots = new HashSet<>(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java index a2fca19c7..deaf46cb5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java @@ -9,6 +9,7 @@ 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.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; @@ -31,6 +32,7 @@ public class FileMoveService { private static final long EVENT_DRAIN_TIMEOUT_MS = 300; private final BookRepository bookRepository; + private final BookAdditionalFileRepository bookFileRepository; private final LibraryRepository libraryRepository; private final FileMoveHelper fileMoveHelper; private final MonitoringRegistrationService monitoringRegistrationService; @@ -82,11 +84,13 @@ public class FileMoveService { Long targetLibraryId = move.getTargetLibraryId(); Long targetLibraryPathId = move.getTargetLibraryPathId(); - Path tempPath = null; - Path currentFilePath = null; + record PlannedMove(Path source, Path temp, Path target) {} + + Map plannedMovesByBookFileId = new HashMap<>(); + Set sourceParentsToCleanup = new HashSet<>(); try { - Optional optionalBook = bookRepository.findById(bookId); + Optional optionalBook = bookRepository.findByIdWithBookFiles(bookId); Optional optionalLibrary = libraryRepository.findById(targetLibraryId); if (optionalBook.isEmpty()) { log.warn("Book not found for move operation: bookId={}", bookId); @@ -108,24 +112,73 @@ public class FileMoveService { } LibraryPathEntity libraryPathEntity = optionalLibraryPathEntity.get(); - currentFilePath = bookEntity.getFullFilePath(); - String pattern = fileMoveHelper.getFileNamingPattern(targetLibrary); - Path newFilePath = fileMoveHelper.generateNewFilePath(bookEntity, libraryPathEntity, pattern); - if (currentFilePath.equals(newFilePath)) { + if (bookEntity.getBookFiles() == null || bookEntity.getBookFiles().isEmpty()) { + log.warn("Book has no files to move: bookId={}", bookId); return; } - tempPath = fileMoveHelper.moveFileWithBackup(currentFilePath); + Path currentPrimaryFilePath = bookEntity.getFullFilePath(); + String pattern = fileMoveHelper.getFileNamingPattern(targetLibrary); + Path newFilePath = fileMoveHelper.generateNewFilePath(bookEntity, libraryPathEntity, pattern); + + if (currentPrimaryFilePath.equals(newFilePath)) { + return; + } - String newFileName = newFilePath.getFileName().toString(); String newFileSubPath = fileMoveHelper.extractSubPath(newFilePath, libraryPathEntity); - bookRepository.updateFileAndLibrary(bookEntity.getId(), newFileSubPath, newFileName, targetLibrary.getId(), libraryPathEntity); + Path targetParentDir = newFilePath.getParent(); - fileMoveHelper.commitMove(tempPath, newFilePath); - tempPath = null; + if (targetParentDir == null) { + log.warn("Target parent directory could not be determined for move operation: bookId={}", bookId); + return; + } - Path libraryRoot = Paths.get(bookEntity.getLibraryPath().getPath()).toAbsolutePath().normalize(); - fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(currentFilePath.getParent(), Set.of(libraryRoot)); + for (var bookFile : bookEntity.getBookFiles()) { + Path sourcePath = bookFile.getFullFilePath(); + Path targetPath; + if (bookFile.isBook()) { + targetPath = fileMoveHelper.generateNewFilePath(bookEntity, bookFile, libraryPathEntity, pattern); + } else { + targetPath = targetParentDir.resolve(bookFile.getFileName()); + } + + if (sourcePath.equals(targetPath)) { + continue; + } + + Path tempPath = fileMoveHelper.moveFileWithBackup(sourcePath); + plannedMovesByBookFileId.put(bookFile.getId(), new PlannedMove(sourcePath, tempPath, targetPath)); + if (sourcePath.getParent() != null) { + sourceParentsToCleanup.add(sourcePath.getParent()); + } + } + + if (plannedMovesByBookFileId.isEmpty()) { + return; + } + + for (var bookFile : bookEntity.getBookFiles()) { + String newFileName; + if (bookFile.isBook()) { + Path targetPath = fileMoveHelper.generateNewFilePath(bookEntity, bookFile, libraryPathEntity, pattern); + newFileName = targetPath.getFileName().toString(); + } else { + newFileName = bookFile.getFileName(); + } + bookFileRepository.updateFileNameAndSubPath(bookFile.getId(), newFileName, newFileSubPath); + } + + bookRepository.updateLibrary(bookEntity.getId(), targetLibrary.getId(), libraryPathEntity); + + for (PlannedMove planned : plannedMovesByBookFileId.values()) { + fileMoveHelper.commitMove(planned.temp(), planned.target()); + } + plannedMovesByBookFileId.clear(); + + Path libraryRoot = Paths.get(libraryPathEntity.getPath()).toAbsolutePath().normalize(); + for (Path sourceParent : sourceParentsToCleanup) { + fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(sourceParent, Set.of(libraryRoot)); + } entityManager.clear(); @@ -136,8 +189,8 @@ public class FileMoveService { } catch (Exception e) { log.error("Error moving file for book ID {}: {}", bookId, e.getMessage(), e); } finally { - if (tempPath != null && currentFilePath != null) { - fileMoveHelper.rollbackMove(tempPath, currentFilePath); + for (PlannedMove planned : plannedMovesByBookFileId.values()) { + fileMoveHelper.rollbackMove(planned.temp(), planned.source()); } } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java index 01e6a1472..2ada8a32d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java @@ -54,7 +54,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { private Book createAndMapBook(LibraryFile libraryFile, String hash) { BookEntity entity = processNewFile(libraryFile); - entity.setCurrentHash(hash); + entity.getPrimaryBookFile().setCurrentHash(hash); entity.setMetadataMatchScore(metadataMatchService.calculateMatchScore(entity)); bookCreatorService.saveConnections(entity); return bookMapper.toBook(entity); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java index 61cbc9807..dbe9568b5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java @@ -59,7 +59,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce @Override public BookEntity processNewFile(LibraryFile libraryFile) { BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, BookFileType.CBX); - bookEntity.setArchiveType(ArchiveUtils.detectArchiveType(new File(FileUtils.getBookFullPath(bookEntity)))); + bookEntity.getPrimaryBookFile().setArchiveType(ArchiveUtils.detectArchiveType(new File(FileUtils.getBookFullPath(bookEntity)))); if (generateCover(bookEntity)) { FileService.setBookCoverPath(bookEntity.getMetadata()); bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash()); @@ -80,16 +80,16 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce if (saved) { return true; } else { - log.warn("Could not save image extracted from CBZ as cover for '{}'", bookEntity.getFileName()); + log.warn("Could not save image extracted from CBZ as cover for '{}'", bookEntity.getPrimaryBookFile().getFileName()); } } finally { image.flush(); // Release resources after processing } } else { - log.warn("Could not find cover image in CBZ file '{}'", bookEntity.getFileName()); + log.warn("Could not find cover image in CBZ file '{}'", bookEntity.getPrimaryBookFile().getFileName()); } } catch (Exception e) { - log.error("Error generating cover for '{}': {}", bookEntity.getFileName(), e.getMessage()); + log.error("Error generating cover for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage()); } return false; } @@ -232,14 +232,14 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity); } } catch (Exception e) { - log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getFileName(), e.getMessage()); + log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage()); // Fallback to filename-derived title setMetadata(bookEntity); } } private void setMetadata(BookEntity bookEntity) { - String baseName = new File(bookEntity.getFileName()).getName(); + String baseName = new File(bookEntity.getPrimaryBookFile().getFileName()).getName(); String extension = FileUtils.getExtension(baseName); if (BookFileType.CBX.supports(extension)) { baseName = baseName.substring(0, baseName.length() - extension.length() - 1); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java index 8ba967d86..b522e115d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java @@ -62,7 +62,7 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc byte[] coverData = epubMetadataExtractor.extractCover(epubFile); if (coverData == null) { - log.warn("No cover image found in EPUB '{}'", bookEntity.getFileName()); + log.warn("No cover image found in EPUB '{}'", bookEntity.getPrimaryBookFile().getFileName()); return false; } @@ -70,7 +70,7 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc try (ByteArrayInputStream bais = new ByteArrayInputStream(coverData)) { BufferedImage originalImage = FileService.readImage(bais); if (originalImage == null) { - log.warn("Failed to decode cover image for EPUB '{}'", bookEntity.getFileName()); + log.warn("Failed to decode cover image for EPUB '{}'", bookEntity.getPrimaryBookFile().getFileName()); return false; } saved = fileService.saveCoverImages(originalImage, bookEntity.getId()); @@ -80,7 +80,7 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc return saved; } catch (Exception e) { - log.error("Error generating cover for EPUB '{}': {}", bookEntity.getFileName(), e.getMessage(), e); + log.error("Error generating cover for EPUB '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage(), e); return false; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java index 1125f6aad..3620f9981 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Fb2Processor.java @@ -62,7 +62,7 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce byte[] coverData = fb2MetadataExtractor.extractCover(fb2File); if (coverData == null || coverData.length == 0) { - log.warn("No cover image found in FB2 '{}'", bookEntity.getFileName()); + log.warn("No cover image found in FB2 '{}'", bookEntity.getPrimaryBookFile().getFileName()); return false; } @@ -70,7 +70,7 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce return saved; } catch (Exception e) { - log.error("Error generating cover for FB2 '{}': {}", bookEntity.getFileName(), e.getMessage(), e); + log.error("Error generating cover for FB2 '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage(), e); return false; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java index ffb3b4413..1059c5265 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/PdfProcessor.java @@ -67,17 +67,17 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce // Note: Catching OOM is generally discouraged, but for batch processing // of potentially large/corrupted PDFs, we prefer graceful degradation // over crashing the entire service. - log.error("Out of memory (heap space exhausted) while generating cover for '{}'. Skipping cover generation.", bookEntity.getFileName()); + log.error("Out of memory (heap space exhausted) while generating cover for '{}'. Skipping cover generation.", bookEntity.getPrimaryBookFile().getFileName()); System.gc(); // Hint to JVM to reclaim memory return false; } catch (NegativeArraySizeException e) { // This can appear on corrupted PDF, or PDF with such large images that the // initial memory buffer is already bigger than the entire JVM heap, therefore // it leads to NegativeArrayException (basically run out of memory, and overflows) - log.warn("Corrupted PDF structure for '{}'. Skipping cover generation.", bookEntity.getFileName()); + log.warn("Corrupted PDF structure for '{}'. Skipping cover generation.", bookEntity.getPrimaryBookFile().getFileName()); return false; } catch (Exception e) { - log.warn("Failed to generate cover for '{}': {}", bookEntity.getFileName(), e.getMessage()); + log.warn("Failed to generate cover for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage()); return false; } } @@ -144,7 +144,7 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce } } catch (Exception e) { - log.warn("Failed to extract PDF metadata for '{}': {}", bookEntity.getFileName(), e.getMessage()); + log.warn("Failed to extract PDF metadata for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage()); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java index 1b5548083..f512a4204 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java @@ -18,7 +18,8 @@ public class KoboCompatibilityService { throw new IllegalArgumentException("Book cannot be null"); } - BookFileType bookType = book.getBookType(); + var primaryFile = book.getPrimaryBookFile(); + BookFileType bookType = primaryFile.getBookType(); if (bookType == null) { return false; } @@ -44,7 +45,7 @@ public class KoboCompatibilityService { } public boolean meetsCbxConversionSizeLimit(BookEntity book) { - if (book == null || book.getBookType() != BookFileType.CBX) { + if (book == null || book.getPrimaryBookFile().getBookType() != BookFileType.CBX) { return false; } @@ -54,7 +55,8 @@ public class KoboCompatibilityService { return false; } - long fileSizeKb = book.getFileSizeKb() != null ? book.getFileSizeKb() : 0; + var pf = book.getPrimaryBookFile(); + long fileSizeKb = pf.getFileSizeKb() != null ? pf.getFileSizeKb() : 0; long limitKb = (long) koboSettings.getConversionLimitInMbForCbx() * 1024; return fileSizeKb <= limitKb; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java index 49e8d3f36..34303a503 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java @@ -86,7 +86,7 @@ public class KoboEntitlementService { .collect(Collectors.toList()); } return books.stream() - .filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB) + .filter(bookEntity -> bookEntity.getPrimaryBookFile().getBookType() == BookFileType.EPUB) .map(book -> ChangedProductMetadata.builder() .changedProductMetadata(BookEntitlementContainer.builder() .bookEntitlement(buildBookEntitlement(book, false)) @@ -232,8 +232,9 @@ public class KoboEntitlementService { KoboBookFormat bookFormat = KoboBookFormat.EPUB3; KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings(); - boolean isEpubFile = book.getBookType() == BookFileType.EPUB; - boolean isCbxFile = book.getBookType() == BookFileType.CBX; + var primaryFile = book.getPrimaryBookFile(); + boolean isEpubFile = primaryFile.getBookType() == BookFileType.EPUB; + boolean isCbxFile = primaryFile.getBookType() == BookFileType.CBX; if (koboSettings != null) { if (isEpubFile && koboSettings.isConvertToKepub()) { @@ -269,7 +270,7 @@ public class KoboEntitlementService { KoboBookMetadata.DownloadUrl.builder() .url(downloadUrl) .format(bookFormat.toString()) - .size(book.getFileSizeKb() * 1024) + .size(primaryFile.getFileSizeKb() * 1024) .build() )) .build(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java index bd1d425a2..35887f703 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java @@ -139,7 +139,7 @@ public class KoboLibrarySnapshotService { .map(book -> { KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book); snapshotBook.setSnapshot(snapshot); - snapshotBook.setFileHash(book.getCurrentHash()); + snapshotBook.setFileHash(book.getPrimaryBookFile().getCurrentHash()); snapshotBook.setMetadataUpdatedAt(book.getMetadataUpdatedAt()); return snapshotBook; }) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java index 3f4e2330e..65d2cb70c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookDeletionService.java @@ -1,10 +1,8 @@ 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.BookFileEntity; 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; @@ -44,7 +42,7 @@ public class BookDeletionService { return; } - List additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds); + List additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds); bookAdditionalFileRepository.deleteAll(additionalFiles); entityManager.flush(); entityManager.clear(); @@ -97,49 +95,31 @@ public class BookDeletionService { } private boolean tryPromoteAlternativeFormatToBook(BookEntity book, List libraryFiles) { - List existingAlternativeFormats = findExistingAlternativeFormats(book, libraryFiles); - - if (existingAlternativeFormats.isEmpty()) { - return false; - } - - BookAdditionalFileEntity promotedFormat = existingAlternativeFormats.getFirst(); - promoteAlternativeFormatToBook(book, promotedFormat); - - bookAdditionalFileRepository.delete(promotedFormat); - - log.info("Promoted alternative format {} to main book for book ID {}", promotedFormat.getFileName(), book.getId()); - return true; - } - - private List findExistingAlternativeFormats(BookEntity book, List libraryFiles) { - Set currentFileNames = libraryFiles.stream() + Set existingFileNames = libraryFiles.stream() .map(LibraryFile::getFileName) .collect(Collectors.toSet()); - if (book.getAdditionalFiles() == null) { - return Collections.emptyList(); + List deletedBookFiles = book.getBookFiles().stream() + .filter(BookFileEntity::isBook) + .filter(bf -> !existingFileNames.contains(bf.getFileName())) + .toList(); + + deletedBookFiles.forEach(bf -> { + book.getBookFiles().remove(bf); + bookAdditionalFileRepository.delete(bf); + }); + + boolean hasRemainingBookFiles = book.getBookFiles().stream() + .anyMatch(BookFileEntity::isBook); + + if (hasRemainingBookFiles) { + bookRepository.save(book); + log.info("Removed {} deleted book file(s) for book ID {}", deletedBookFiles.size(), book.getId()); + return true; } - - 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()); + return false; } - 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)) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java index 1f9edac02..ab1e38ebe 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessor.java @@ -2,11 +2,10 @@ package com.adityachandel.booklore.service.library; 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.BookFileEntity; 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.enums.BookFileType; import com.adityachandel.booklore.model.enums.LibraryScanMode; @@ -84,20 +83,20 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { private GetOrCreateBookResult getOrCreateBookInDirectory(Path directoryPath, List filesInDirectory, LibraryEntity libraryEntity) { var existingBook = findExistingBookInDirectory(directoryPath, libraryEntity); if (existingBook.isPresent()) { - log.debug("Found existing book in directory {}: {}", directoryPath, existingBook.get().getFileName()); + log.debug("Found existing book in directory {}: {}", directoryPath, existingBook.get().getPrimaryBookFile().getFileName()); return new GetOrCreateBookResult(existingBook, filesInDirectory); } Optional parentBook = findBookInParentDirectories(directoryPath, libraryEntity); if (parentBook.isPresent()) { - log.debug("Found parent book for directory {}: {}", directoryPath, parentBook.get().getFileName()); + log.debug("Found parent book for directory {}: {}", directoryPath, parentBook.get().getPrimaryBookFile().getFileName()); return new GetOrCreateBookResult(parentBook, filesInDirectory); } log.debug("No existing book found, creating new book from directory: {}", directoryPath); Optional newBook = createNewBookFromDirectory(directoryPath, filesInDirectory, libraryEntity); if (newBook.isPresent()) { - log.info("Created new book: {}", newBook.get().bookEntity.getFileName()); + log.info("Created new book: {}", newBook.get().bookEntity.getPrimaryBookFile().getFileName()); var remainingFiles = filesInDirectory.stream() .filter(file -> !file.equals(newBook.get().libraryFile)) .toList(); @@ -140,7 +139,7 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { Optional parentBook = bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith( directoryLibraryPathEntity.getId(), parentPath).stream() - .filter(book -> book.getFileSubPath().equals(parentPath)) + .filter(book -> book.getPrimaryBookFile().getFileSubPath().equals(parentPath)) .findFirst(); if (parentBook.isPresent()) { return parentBook; @@ -172,7 +171,7 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { BookEntity bookEntity = bookRepository.getReferenceById(result.getBook().getId()); if (bookEntity.getFullFilePath().equals(bookFile.getFullPath())) { - log.info("Successfully created new book: {}", bookEntity.getFileName()); + log.info("Successfully created new book: {}", bookEntity.getPrimaryBookFile().getFileName()); } else { log.warn("Found duplicate book with different path: {} vs {}", bookEntity.getFullFilePath(), bookFile.getFullPath()); } @@ -205,15 +204,15 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { private void processAdditionalFiles(BookEntity existingBook, List filesInDirectory) { for (LibraryFile file : filesInDirectory) { Optional extension = BookFileExtension.fromFileName(file.getFileName()); - AdditionalFileType fileType = extension.isPresent() ? - AdditionalFileType.ALTERNATIVE_FORMAT : AdditionalFileType.SUPPLEMENTARY; + boolean isBook = extension.isPresent(); + BookFileType bookType = extension.map(BookFileExtension::getType).orElse(null); - createAdditionalFileIfNotExists(existingBook, file, fileType); + createAdditionalFileIfNotExists(existingBook, file, isBook, bookType); } } - private void createAdditionalFileIfNotExists(BookEntity bookEntity, LibraryFile file, AdditionalFileType fileType) { - Optional existingFile = bookAdditionalFileRepository + private void createAdditionalFileIfNotExists(BookEntity bookEntity, LibraryFile file, boolean isBook, BookFileType bookType) { + Optional existingFile = bookAdditionalFileRepository .findByLibraryPath_IdAndFileSubPathAndFileName( file.getLibraryPathEntity().getId(), file.getFileSubPath(), file.getFileName()); @@ -223,11 +222,12 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { } String hash = FileFingerprint.generateHash(file.getFullPath()); - BookAdditionalFileEntity additionalFile = BookAdditionalFileEntity.builder() + BookFileEntity additionalFile = BookFileEntity.builder() .book(bookEntity) .fileName(file.getFileName()) .fileSubPath(file.getFileSubPath()) - .additionalFileType(fileType) + .isBookFormat(isBook) + .bookType(bookType) .fileSizeKb(FileUtils.getFileSizeInKb(file.getFullPath())) .initialHash(hash) .currentHash(hash) @@ -235,12 +235,12 @@ public class FolderAsBookFileProcessor implements LibraryFileProcessor { .build(); try { - log.debug("Creating additional file: {} (type: {})", file.getFileName(), fileType); + log.debug("Creating additional file: {} (isBook: {}, type: {})", file.getFileName(), isBook, bookType); bookAdditionalFileRepository.save(additionalFile); log.debug("Successfully created additional file: {}", file.getFileName()); } catch (Exception e) { - bookEntity.getAdditionalFiles().removeIf(a -> a.equals(additionalFile)); + bookEntity.getBookFiles().removeIf(a -> a.equals(additionalFile)); log.error("Error creating additional file {}: {}", file.getFileName(), e.getMessage(), e); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java index 64daab00d..6ca6c1a3d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryProcessingService.java @@ -2,7 +2,7 @@ package com.adityachandel.booklore.service.library; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.model.dto.settings.LibraryFile; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.websocket.LogNotification; @@ -61,7 +61,7 @@ public class LibraryProcessingService { notificationService.sendMessage(Topic.LOG, LogNotification.info("Started refreshing library: " + libraryEntity.getName())); LibraryFileProcessor processor = fileProcessorRegistry.getProcessor(libraryEntity); List libraryFiles = libraryFileHelper.getLibraryFiles(libraryEntity, processor); - List additionalFileIds = detectDeletedAdditionalFiles(libraryFiles, libraryEntity); + List additionalFileIds = detectDeletedAdditionalFiles(libraryFiles, libraryEntity, processor); if (!additionalFileIds.isEmpty()) { log.info("Detected {} removed additional files in library: {}", additionalFileIds.size(), libraryEntity.getName()); bookDeletionService.deleteRemovedAdditionalFiles(additionalFileIds); @@ -112,10 +112,10 @@ public class LibraryProcessingService { } private String generateUniqueKey(BookEntity book) { - return generateKey(book.getLibraryPath().getId(), book.getFileSubPath(), book.getFileName()); + return generateKey(book.getLibraryPath().getId(), book.getPrimaryBookFile().getFileSubPath(), book.getPrimaryBookFile().getFileName()); } - private String generateUniqueKey(BookAdditionalFileEntity file) { + private String generateUniqueKey(BookFileEntity file) { // Additional files inherit library path from their parent book return generateKey(file.getBook().getLibraryPath().getId(), file.getFileSubPath(), file.getFileName()); } @@ -129,16 +129,18 @@ public class LibraryProcessingService { return libraryPathId + ":" + safeSubPath + ":" + fileName; } - protected List detectDeletedAdditionalFiles(List libraryFiles, LibraryEntity libraryEntity) { - Set currentFileNames = libraryFiles.stream() - .map(LibraryFile::getFileName) + protected List detectDeletedAdditionalFiles(List libraryFiles, LibraryEntity libraryEntity, LibraryFileProcessor processor) { + Set currentFileKeys = libraryFiles.stream() + .map(this::generateUniqueKey) .collect(Collectors.toSet()); - List allAdditionalFiles = bookAdditionalFileRepository.findByLibraryId(libraryEntity.getId()); + List allAdditionalFiles = bookAdditionalFileRepository.findByLibraryId(libraryEntity.getId()); return allAdditionalFiles.stream() - .filter(additionalFile -> !currentFileNames.contains(additionalFile.getFileName())) - .map(BookAdditionalFileEntity::getId) + // Only check files that would be scanned: book formats always, non-book files only if processor supports them + .filter(additionalFile -> additionalFile.isBookFormat() || processor.supportsSupplementaryFiles()) + .filter(additionalFile -> !currentFileKeys.contains(generateUniqueKey(additionalFile))) + .map(BookFileEntity::getId) .collect(Collectors.toList()); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryRescanHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryRescanHelper.java index 742acbc5c..e132314d4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryRescanHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryRescanHelper.java @@ -71,16 +71,16 @@ public class LibraryRescanHelper { break; } - log.info("Processing book: library={}, bookId={}, fileName={}", library.getName(), bookEntity.getId(), bookEntity.getFileName()); + log.info("Processing book: library={}, bookId={}, fileName={}", library.getName(), bookEntity.getId(), bookEntity.getPrimaryBookFile().getFileName()); int progressPercentage = totalBooks > 0 ? (processedBooks * 100) / totalBooks : 0; sendTaskProgressNotification(taskId, progressPercentage, - String.format("Processing: %s (Library: %s)", bookEntity.getFileName(), library.getName()), + String.format("Processing: %s (Library: %s)", bookEntity.getPrimaryBookFile().getFileName(), library.getName()), TaskStatus.IN_PROGRESS); try { - BookMetadata bookMetadata = metadataExtractorFactory.extractMetadata(bookEntity.getBookType(), bookEntity.getFullFilePath().toFile()); + BookMetadata bookMetadata = metadataExtractorFactory.extractMetadata(bookEntity.getPrimaryBookFile().getBookType(), bookEntity.getFullFilePath().toFile()); if (bookMetadata == null) { log.warn("No metadata extracted for book id={} path={}", bookEntity.getId(), bookEntity.getFullFilePath()); continue; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookCoverService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookCoverService.java index adeb06429..e45c646c9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookCoverService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookCoverService.java @@ -140,7 +140,7 @@ public class BookCoverService { if (isCoverLocked(bookEntity)) { throw ApiError.METADATA_LOCKED.createException(); } - BookFileProcessor processor = processorRegistry.getProcessorOrThrow(bookEntity.getBookType()); + BookFileProcessor processor = processorRegistry.getProcessorOrThrow(bookEntity.getPrimaryBookFile().getBookType()); boolean success = processor.generateCover(bookEntity); if (!success) { throw ApiError.FAILED_TO_REGENERATE_COVER.createException(); @@ -165,7 +165,7 @@ public class BookCoverService { try { List books = bookQueryService.getAllFullBookEntities().stream() .filter(book -> !isCoverLocked(book)) - .map(book -> new BookRegenerationInfo(book.getId(), book.getMetadata().getTitle(), book.getBookType(), false)) + .map(book -> new BookRegenerationInfo(book.getId(), book.getMetadata().getTitle(), book.getPrimaryBookFile().getBookType(), false)) .toList(); int total = books.size(); notificationService.sendMessage(Topic.LOG, LogNotification.info("Started regenerating covers for " + total + " books")); @@ -180,7 +180,7 @@ public class BookCoverService { transactionTemplate.execute(status -> { bookRepository.findById(bookInfo.id()).ifPresent(book -> { - BookFileProcessor processor = processorRegistry.getProcessorOrThrow(bookInfo.bookType()); + BookFileProcessor processor = processorRegistry.getProcessorOrThrow(book.getPrimaryBookFile().getBookType()); boolean success = processor.generateCover(book); if (success) { @@ -331,7 +331,7 @@ public class BookCoverService { private List getUnlockedBookRegenerationInfos(Set bookIds) { return bookQueryService.findAllWithMetadataByIds(bookIds).stream() .filter(book -> !isCoverLocked(book)) - .map(book -> new BookRegenerationInfo(book.getId(), book.getMetadata().getTitle(), book.getBookType(), false)) + .map(book -> new BookRegenerationInfo(book.getId(), book.getMetadata().getTitle(), book.getPrimaryBookFile().getBookType(), false)) .toList(); } @@ -352,12 +352,12 @@ public class BookCoverService { MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); - if ((bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { - metadataWriterFactory.getWriter(bookEntity.getBookType()) + if ((bookEntity.getPrimaryBookFile().getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { + metadataWriterFactory.getWriter(bookEntity.getPrimaryBookFile().getBookType()) .ifPresent(writer -> { writerAction.accept(writer, bookEntity); String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); - bookEntity.setCurrentHash(newHash); + bookEntity.getPrimaryBookFile().setCurrentHash(newHash); }); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index c5bb2ca61..99e954839 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -125,8 +125,8 @@ public class BookMetadataService { public BookMetadata getComicInfoMetadata(long bookId) { log.info("Extracting ComicInfo metadata for book ID: {}", bookId); BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - if (bookEntity.getBookType() != BookFileType.CBX) { - log.info("Unsupported operation for file type: {}", bookEntity.getBookType().name()); + if (bookEntity.getPrimaryBookFile().getBookType() != BookFileType.CBX) { + log.info("Unsupported operation for file type: {}", bookEntity.getPrimaryBookFile().getBookType().name()); return null; } return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index cb39c8426..dc70a38d7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -86,7 +86,8 @@ public class BookMetadataUpdater { MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); MetadataPersistenceSettings.SaveToOriginalFile writeToFile = settings.getSaveToOriginalFile(); - BookFileType bookType = bookEntity.getBookType(); + var primaryFile = bookEntity.getPrimaryBookFile(); + BookFileType bookType = primaryFile.getBookType(); boolean hasValueChangesForFileWrite = MetadataChangeDetector.hasValueChangesForFileWrite(newMetadata, metadata, clearFlags); @@ -120,7 +121,7 @@ public class BookMetadataUpdater { writer.saveMetadataToFile(file, metadata, thumbnailUrl, clearFlags); String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); bookEntity.setMetadataForWriteUpdatedAt(Instant.now()); - bookEntity.setCurrentHash(newHash); + primaryFile.setCurrentHash(newHash); bookRepository.save(bookEntity); } catch (Exception e) { log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); @@ -134,8 +135,9 @@ public class BookMetadataUpdater { BookEntity book = metadata.getBook(); FileMoveResult result = fileMoveService.moveSingleFile(book); if (result.isMoved()) { - book.setFileName(result.getNewFileName()); - book.setFileSubPath(result.getNewFileSubPath()); + var bookPrimaryFile = book.getPrimaryBookFile(); + bookPrimaryFile.setFileName(result.getNewFileName()); + bookPrimaryFile.setFileSubPath(result.getNewFileSubPath()); } } catch (Exception e) { log.warn("Failed to move files for book ID {} after metadata update: {}", bookId, e.getMessage()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataManagementService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataManagementService.java index 4e4b70c93..432d18008 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataManagementService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataManagementService.java @@ -59,21 +59,22 @@ public class MetadataManagementService { BookEntity book = metadata.getBook(); boolean bookModified = false; - BookFileType bookType = book.getBookType(); + var primaryFile = book.getPrimaryBookFile(); + BookFileType bookType = primaryFile.getBookType(); Optional writerOpt = metadataWriterFactory.getWriter(bookType); if (writerOpt.isPresent()) { File file = book.getFullFilePath().toFile(); writerOpt.get().saveMetadataToFile(file, metadata, null, null); String newHash = FileFingerprint.generateHash(book.getFullFilePath()); - book.setCurrentHash(newHash); + primaryFile.setCurrentHash(newHash); bookModified = true; } if (moveFile) { FileMoveResult result = fileMoveService.moveSingleFile(book); if (result.isMoved()) { - book.setFileName(result.getNewFileName()); - book.setFileSubPath(result.getNewFileSubPath()); + primaryFile.setFileName(result.getNewFileName()); + primaryFile.setFileSubPath(result.getNewFileSubPath()); bookModified = true; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index bbceaec71..73ff3181c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -115,7 +115,7 @@ public class MetadataRefreshService { .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); try { if (book.getMetadata().areAllFieldsLocked()) { - log.info("Skipping locked book: {}", book.getFileName()); + log.info("Skipping locked book: {}", book.getPrimaryBookFile().getFileName()); sendBatchProgressNotification(jobId, finalCompletedCount, totalBooks, "Skipped locked book: " + book.getMetadata().getTitle(), MetadataFetchTaskStatus.IN_PROGRESS, isReviewMode); return null; } @@ -161,11 +161,11 @@ public class MetadataRefreshService { sendBatchProgressNotification(jobId, finalCompletedCount + 1, totalBooks, "Processed: " + book.getMetadata().getTitle(), MetadataFetchTaskStatus.IN_PROGRESS, bookReviewMode); } catch (Exception e) { if (Thread.currentThread().isInterrupted()) { - log.info("Processing interrupted for book: {}", book.getFileName()); + log.info("Processing interrupted for book: {}", book.getPrimaryBookFile().getFileName()); status.setRollbackOnly(); return null; } - log.error("Metadata update failed for book: {}", book.getFileName(), e); + log.error("Metadata update failed for book: {}", book.getPrimaryBookFile().getFileName(), e); sendBatchProgressNotification(jobId, finalCompletedCount, totalBooks, String.format("Failed to process: %s - %s", book.getMetadata().getTitle(), e.getMessage()), MetadataFetchTaskStatus.ERROR, isReviewMode); } bookRepository.saveAndFlush(book); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java index 372efde49..6064f3340 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java @@ -45,10 +45,10 @@ public class PopulateFileHashesMigration implements Migration { try { String hash = FileFingerprint.generateHash(path); - if (book.getInitialHash() == null) { - book.setInitialHash(hash); + if (book.getPrimaryBookFile().getInitialHash() == null) { + book.getPrimaryBookFile().setInitialHash(hash); } - book.setCurrentHash(hash); + book.getPrimaryBookFile().setCurrentHash(hash); updated++; } catch (Exception e) { log.error("Failed to compute hash for file: {}", path, e); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java index dbe38ff63..e91c77420 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java @@ -36,7 +36,7 @@ public class PopulateMissingFileSizesMigration implements Migration { for (BookEntity book : books) { Long sizeInKb = FileUtils.getFileSizeInKb(book); if (sizeInKb != null) { - book.setFileSizeKb(sizeInKb); + book.getPrimaryBookFile().setFileSizeKb(sizeInKb); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java index d55dfe789..c4b364368 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java @@ -3,14 +3,14 @@ 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.dto.AdditionalFile; +import com.adityachandel.booklore.model.dto.BookFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; 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.BookFileType; import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; @@ -18,6 +18,7 @@ import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.file.FileFingerprint; import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.service.file.FileMovingHelper; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.service.metadata.extractor.MetadataExtractorFactory; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.RequiredArgsConstructor; @@ -52,6 +53,7 @@ public class FileUploadService { private final MetadataExtractorFactory metadataExtractorFactory; private final AdditionalFileMapper additionalFileMapper; private final FileMovingHelper fileMovingHelper; + private final MonitoringRegistrationService monitoringRegistrationService; public void uploadFile(MultipartFile file, long libraryId, long pathId) { validateFile(file); @@ -86,27 +88,47 @@ public class FileUploadService { } @Transactional - public AdditionalFile uploadAdditionalFile(Long bookId, MultipartFile file, AdditionalFileType additionalFileType, String description) { + public BookFile uploadAdditionalFile(Long bookId, MultipartFile file, boolean isBook, BookFileType bookType, String description) { final BookEntity book = findBookById(bookId); final String originalFileName = getValidatedFileName(file); + final Long libraryId = book.getLibrary() != null ? book.getLibrary().getId() : null; final String sanitizedFileName = PathPatternResolver.truncateFilenameWithExtension(originalFileName); Path tempPath = null; + boolean monitoringUnregistered = false; try { tempPath = createTempFile(UPLOAD_TEMP_PREFIX, sanitizedFileName); file.transferTo(tempPath); final String fileHash = FileFingerprint.generateHash(tempPath); - validateAlternativeFormatDuplicate(additionalFileType, fileHash); + if (isBook) { + validateAlternativeFormatDuplicate(fileHash); + } - final Path finalPath = buildAdditionalFilePath(book, sanitizedFileName); + final Path finalPath; + final String finalFileName; + if (isBook) { + String pattern = fileMovingHelper.getFileNamingPattern(book.getLibrary()); + String resolvedRelativePath = PathPatternResolver.resolvePattern(book.getMetadata(), pattern, sanitizedFileName); + finalFileName = Paths.get(resolvedRelativePath).getFileName().toString(); + finalPath = buildAdditionalFilePath(book, finalFileName); + } else { + finalFileName = sanitizedFileName; + finalPath = buildAdditionalFilePath(book, sanitizedFileName); + } validateFinalPath(finalPath); + + if (libraryId != null) { + log.debug("Unregistering library {} for monitoring", libraryId); + monitoringRegistrationService.unregisterLibrary(libraryId); + monitoringUnregistered = true; + } moveFileToFinalLocation(tempPath, finalPath); log.info("Additional file uploaded to final location: {}", finalPath); - final BookAdditionalFileEntity entity = createAdditionalFileEntity(book, sanitizedFileName, additionalFileType, file.getSize(), fileHash, description); - final BookAdditionalFileEntity savedEntity = additionalFileRepository.save(entity); + final BookFileEntity entity = createAdditionalFileEntity(book, finalFileName, isBook, bookType, file.getSize(), fileHash, description); + final BookFileEntity savedEntity = additionalFileRepository.save(entity); return additionalFileMapper.toAdditionalFile(savedEntity); @@ -114,6 +136,19 @@ public class FileUploadService { log.error("Failed to upload additional file for book {}: {}", bookId, sanitizedFileName, e); throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); } finally { + if (monitoringUnregistered && libraryId != null) { + try { + if (book.getLibrary() != null && book.getLibrary().getLibraryPaths() != null) { + for (LibraryPathEntity libPath : book.getLibrary().getLibraryPaths()) { + Path libraryRoot = Path.of(libPath.getPath()); + log.debug("Re-registering library {} for monitoring", libraryId); + monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot); + } + } + } catch (Exception e) { + log.warn("Failed to re-register library {} for monitoring after additional file upload: {}", libraryId, e.getMessage()); + } + } cleanupTempFile(tempPath); } } @@ -195,25 +230,26 @@ public class FileUploadService { Files.move(sourcePath, targetPath); } - private void validateAlternativeFormatDuplicate(AdditionalFileType additionalFileType, String fileHash) { - if (additionalFileType == AdditionalFileType.ALTERNATIVE_FORMAT) { - final Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); - if (existingAltFormat.isPresent()) { - throw new IllegalArgumentException("Alternative format file already exists with same content"); - } + private void validateAlternativeFormatDuplicate(String fileHash) { + final Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); + if (existingAltFormat.isPresent()) { + throw new IllegalArgumentException("Alternative format file already exists with same content"); } } private Path buildAdditionalFilePath(BookEntity book, String fileName) { - return Paths.get(book.getLibraryPath().getPath(), book.getFileSubPath(), fileName); + final BookFileEntity primaryFile = book.getPrimaryBookFile(); + return Paths.get(book.getLibraryPath().getPath(), primaryFile.getFileSubPath(), fileName); } - private BookAdditionalFileEntity createAdditionalFileEntity(BookEntity book, String fileName, AdditionalFileType additionalFileType, long fileSize, String fileHash, String description) { - return BookAdditionalFileEntity.builder() + private BookFileEntity createAdditionalFileEntity(BookEntity book, String fileName, boolean isBook, BookFileType bookType, long fileSize, String fileHash, String description) { + final BookFileEntity primaryFile = book.getPrimaryBookFile(); + return BookFileEntity.builder() .book(book) .fileName(fileName) - .fileSubPath(book.getFileSubPath()) - .additionalFileType(additionalFileType) + .fileSubPath(primaryFile.getFileSubPath()) + .isBookFormat(isBook) + .bookType(bookType) .fileSizeKb(fileSize / BYTES_TO_KB_DIVISOR) .initialHash(fileHash) .currentHash(fileHash) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java index 1deaa2034..bf94378a8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java @@ -40,11 +40,12 @@ public class BookFilePersistenceService { String newSubPath = FileUtils.getRelativeSubPath(newLibraryPath.getPath(), path); - boolean pathChanged = !Objects.equals(newSubPath, book.getFileSubPath()) || !Objects.equals(newLibraryPath.getId(), book.getLibraryPath().getId()); + var primaryFile = book.getPrimaryBookFile(); + boolean pathChanged = !Objects.equals(newSubPath, primaryFile.getFileSubPath()) || !Objects.equals(newLibraryPath.getId(), book.getLibraryPath().getId()); if (pathChanged || Boolean.TRUE.equals(book.getDeleted())) { book.setLibraryPath(newLibraryPath); - book.setFileSubPath(newSubPath); + primaryFile.setFileSubPath(newSubPath); book.setDeleted(Boolean.FALSE); bookRepository.save(book); log.info("[FILE_CREATE] Updated path / undeleted existing book with hash '{}': '{}'", currentHash, path); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileUtils.java index 98537a795..aa360d784 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileUtils.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileUtils.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.util; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -22,7 +23,9 @@ public class FileUtils { private final String FILE_NOT_FOUND_MESSAGE = "File does not exist: "; public String getBookFullPath(BookEntity bookEntity) { - return Path.of(bookEntity.getLibraryPath().getPath(), bookEntity.getFileSubPath(), bookEntity.getFileName()) + BookFileEntity bookFile = bookEntity.getPrimaryBookFile(); + + return Path.of(bookEntity.getLibraryPath().getPath(), bookFile.getFileSubPath(), bookFile.getFileName()) .normalize() .toString() .replace("\\", "/"); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/PathPatternResolver.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/PathPatternResolver.java index f353e0452..5f4eb7548 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/PathPatternResolver.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/PathPatternResolver.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.util; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -35,7 +36,11 @@ public class PathPatternResolver { private final Pattern SLASH_PATTERN = Pattern.compile("/"); public String resolvePattern(BookEntity book, String pattern) { - String currentFilename = book.getFileName() != null ? book.getFileName().trim() : ""; + return resolvePattern(book, book.getPrimaryBookFile(), pattern); + } + + public String resolvePattern(BookEntity book, BookFileEntity bookFile, String pattern) { + String currentFilename = bookFile != null && bookFile.getFileName() != null ? bookFile.getFileName().trim() : ""; return resolvePattern(book.getMetadata(), pattern, currentFilename); } diff --git a/booklore-api/src/main/resources/db/migration/V91__Refactor_book_and_book_alternative_files.sql b/booklore-api/src/main/resources/db/migration/V91__Refactor_book_and_book_alternative_files.sql new file mode 100644 index 000000000..562bf4395 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V91__Refactor_book_and_book_alternative_files.sql @@ -0,0 +1,107 @@ +-- Migrate file specific data from book to book_file +RENAME TABLE book_additional_file TO book_file_old; +CREATE TABLE book_file LIKE book_file_old; + ALTER TABLE book_file + ADD CONSTRAINT fk_book_file_book + FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE; +ALTER TABLE book_file ADD COLUMN is_book boolean DEFAULT false; +ALTER TABLE book_file ADD COLUMN book_type varchar(32); +ALTER TABLE book_file ADD COLUMN archive_type VARCHAR(255); + +-- Drop the column before importing the data to avoid duplicate index errors +ALTER TABLE book_file DROP COLUMN alt_format_current_hash; + +INSERT INTO book_file (book_id, file_name, file_sub_path, is_book, book_type, archive_type, file_size_kb, initial_hash, added_on, current_hash) +SELECT id, file_name, file_sub_path, true, CASE when book_type = 0 then 'PDF' when book_type = 1 then 'EPUB' when book_type = 2 then 'CBX' end, archive_type, file_size_kb, initial_hash, added_on, current_hash FROM book; + +INSERT INTO book_file (book_id, file_name, file_sub_path, file_size_kb, initial_hash, current_hash, description, added_on, additional_file_type) +SELECT book_id, file_name, file_sub_path, file_size_kb, initial_hash, current_hash, description, added_on, additional_file_type FROM book_file_old; + +UPDATE book_file SET is_book = true WHERE additional_file_type = 'ALTERNATIVE_FORMAT'; +ALTER TABLE book_file DROP COLUMN additional_file_type; + +-- Set book_type for existing book files +UPDATE book_file +SET book_type = CASE + WHEN LOWER(file_name) LIKE '%.epub' THEN 'EPUB' + WHEN LOWER(file_name) LIKE '%.pdf' THEN 'PDF' + WHEN LOWER(file_name) LIKE '%.cbz' THEN 'CBX' + WHEN LOWER(file_name) LIKE '%.cbr' THEN 'CBX' + WHEN LOWER(file_name) LIKE '%.cb7' THEN 'CBX' + WHEN LOWER(file_name) LIKE '%.fb2' THEN 'FB2' + ELSE book_type +END +WHERE is_book = 1 + AND book_type IS NULL; + + +-- Prevent duplicates of book files (is_book=true) by (library_id, library_path_id, file_sub_path, file_name) +-- MariaDB/MySQL do not support UNIQUE constraints across multiple tables, so we need to enforce via triggers. +DELIMITER $$ +CREATE OR REPLACE PROCEDURE assert_no_duplicate_book_file( + IN p_book_file_id BIGINT, + IN p_book_id BIGINT, + IN p_file_name VARCHAR(1000), + IN p_file_sub_path VARCHAR(512), + IN p_is_book BOOLEAN +) +BEGIN + DECLARE v_library_id BIGINT; + DECLARE v_library_path_id BIGINT; + + IF p_is_book = true THEN + SELECT b.library_id, b.library_path_id + INTO v_library_id, v_library_path_id + FROM book b + WHERE b.id = p_book_id; + + IF v_library_id IS NOT NULL AND EXISTS ( + SELECT 1 + FROM book_file bf + INNER JOIN book b2 ON b2.id = bf.book_id + WHERE bf.is_book = true + AND bf.file_name = p_file_name + AND bf.file_sub_path = p_file_sub_path + AND b2.library_id = v_library_id + AND b2.library_path_id = v_library_path_id + AND (p_book_file_id IS NULL OR bf.id <> p_book_file_id) + LIMIT 1 + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Duplicate book file detected for library/path/subpath/name'; + END IF; + END IF; +END$$ + +CREATE OR REPLACE TRIGGER trg_book_file_prevent_duplicate_book_insert +BEFORE INSERT ON book_file +FOR EACH ROW +BEGIN + CALL assert_no_duplicate_book_file(NULL, NEW.book_id, NEW.file_name, NEW.file_sub_path, NEW.is_book); +END$$ + +CREATE OR REPLACE TRIGGER trg_book_file_prevent_duplicate_book_update +BEFORE UPDATE ON book_file +FOR EACH ROW +BEGIN + CALL assert_no_duplicate_book_file(OLD.id, NEW.book_id, NEW.file_name, NEW.file_sub_path, NEW.is_book); +END$$ +DELIMITER ; + +-- Regenerate virtual column for alternative book format files, create the index without UNIQUE constraint +ALTER TABLE book_file ADD COLUMN alt_format_current_hash VARCHAR(128) AS (CASE WHEN is_book = true THEN current_hash END) STORED; +ALTER TABLE book_file ADD INDEX idx_book_file_current_hash_alt_format (alt_format_current_hash); + +-- Remove constraint from book table +ALTER TABLE book DROP INDEX IF EXISTS unique_library_file_path; + +-- Remove migrated fields from the book table +ALTER TABLE book DROP COLUMN file_name; +ALTER TABLE book DROP COLUMN file_sub_path; +ALTER TABLE book DROP COLUMN book_type; +ALTER TABLE book DROP COLUMN file_size_kb; +ALTER TABLE book DROP COLUMN initial_hash; +ALTER TABLE book DROP COLUMN current_hash; +ALTER TABLE book DROP COLUMN archive_type; + +DROP TABLE book_file_old; \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/PathPatternResolverTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/PathPatternResolverTest.java index 8484cedbd..ebd0cb8fa 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/PathPatternResolverTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/PathPatternResolverTest.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore; import com.adityachandel.booklore.model.entity.AuthorEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.util.PathPatternResolver; @@ -23,10 +24,12 @@ class PathPatternResolverTest { String publisher, String isbn13, String isbn10, String fileName) { BookEntity book = mock(BookEntity.class); + BookFileEntity primaryFile = mock(BookFileEntity.class); BookMetadataEntity metadata = mock(BookMetadataEntity.class); when(book.getMetadata()).thenReturn(metadata); - when(book.getFileName()).thenReturn(fileName); + when(book.getPrimaryBookFile()).thenReturn(primaryFile); + when(primaryFile.getFileName()).thenReturn(fileName); when(metadata.getTitle()).thenReturn(title); when(metadata.getSubtitle()).thenReturn(subtitle); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/mapper/BookMapperTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/mapper/BookMapperTest.java index 8b3777131..1c2dec944 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/mapper/BookMapperTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/mapper/BookMapperTest.java @@ -1,11 +1,16 @@ package com.adityachandel.booklore.mapper; import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.entity.BookFileEntity; 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 org.junit.jupiter.api.Test; import org.mapstruct.factory.Mappers; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class BookMapperTest { @@ -16,18 +21,36 @@ class BookMapperTest { void shouldMapExistingFieldsCorrectly() { LibraryEntity library = new LibraryEntity(); library.setId(123L); + library.setName("Test Library"); + + LibraryPathEntity libraryPath = new LibraryPathEntity(); + libraryPath.setId(1L); + libraryPath.setPath("/tmp"); BookEntity entity = new BookEntity(); entity.setId(1L); - entity.setFileName("Test Book"); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setFileName("Test Book"); + primaryFile.setFileSubPath("."); + primaryFile.setBookFormat(true); + primaryFile.setBookType(BookFileType.EPUB); + entity.setBookFiles(List.of(primaryFile)); entity.setLibrary(library); + entity.setLibraryPath(libraryPath); Book dto = mapper.toBook(entity); assertThat(dto).isNotNull(); assertThat(dto.getId()).isEqualTo(1L); - assertThat(dto.getFileName()).isEqualTo("Test Book"); assertThat(dto.getLibraryId()).isEqualTo(123L); + assertThat(dto.getLibraryName()).isEqualTo("Test Library"); + assertThat(dto.getLibraryPath()).isNotNull(); + assertThat(dto.getLibraryPath().getId()).isEqualTo(1L); + assertThat(dto.getBookType()).isEqualTo(BookFileType.EPUB); + assertThat(dto.getAlternativeFormats()).isEmpty(); + assertThat(dto.getSupplementaryFiles()).isEmpty(); } } \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/repository/BookOpdsRepositoryDataJpaTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/repository/BookOpdsRepositoryDataJpaTest.java new file mode 100644 index 000000000..133427eb6 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/repository/BookOpdsRepositoryDataJpaTest.java @@ -0,0 +1,72 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.enums.LibraryScanMode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.TestPropertySource; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@TestPropertySource(properties = { + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class BookOpdsRepositoryDataJpaTest { + + @Autowired + private BookOpdsRepository bookOpdsRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + void contextLoads() { + assertThat(bookOpdsRepository).isNotNull(); + } + + @Test + void findAllWithMetadataByIds_executesAgainstJpaMetamodel() { + LibraryEntity library = LibraryEntity.builder() + .name("Test Library") + .icon("book") + .scanMode(LibraryScanMode.FILE_AS_BOOK) + .watch(false) + .build(); + library = entityManager.persistAndFlush(library); + + LibraryPathEntity libraryPath = LibraryPathEntity.builder() + .library(library) + .path("/test/path") + .build(); + libraryPath = entityManager.persistAndFlush(libraryPath); + + BookEntity book = BookEntity.builder() + .library(library) + .libraryPath(libraryPath) + .addedOn(Instant.now()) + .deleted(false) + .build(); + book = entityManager.persistAndFlush(book); + + BookMetadataEntity metadata = BookMetadataEntity.builder() + .book(book) + .bookId(book.getId()) + .title("Test Title") + .build(); + entityManager.persistAndFlush(metadata); + + List result = bookOpdsRepository.findAllWithMetadataByIds(List.of(book.getId())); + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(book.getId()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java index ca9abae76..5d95c4824 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java @@ -1,11 +1,10 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.mapper.AdditionalFileMapper; -import com.adityachandel.booklore.model.dto.AdditionalFile; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.dto.BookFile; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.service.file.AdditionalFileService; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; @@ -51,8 +50,8 @@ class AdditionalFileServiceTest { @TempDir Path tempDir; - private BookAdditionalFileEntity fileEntity; - private AdditionalFile additionalFile; + private BookFileEntity fileEntity; + private BookFile additionalFile; private BookEntity bookEntity; @BeforeEach @@ -68,26 +67,26 @@ class AdditionalFileServiceTest { bookEntity.setId(100L); bookEntity.setLibraryPath(libraryPathEntity); - fileEntity = new BookAdditionalFileEntity(); + fileEntity = new BookFileEntity(); fileEntity.setId(1L); fileEntity.setBook(bookEntity); fileEntity.setFileName("test-file.pdf"); fileEntity.setFileSubPath("."); - fileEntity.setAdditionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT); + fileEntity.setBookFormat(true); - additionalFile = mock(AdditionalFile.class); + additionalFile = mock(BookFile.class); } @Test void getAdditionalFilesByBookId_WhenFilesExist_ShouldReturnMappedFiles() { Long bookId = 100L; - List entities = List.of(fileEntity); - List expectedFiles = List.of(additionalFile); + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - List result = additionalFileService.getAdditionalFilesByBookId(bookId); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); assertEquals(expectedFiles, result); verify(additionalFileRepository).findByBookId(bookId); @@ -97,13 +96,13 @@ class AdditionalFileServiceTest { @Test void getAdditionalFilesByBookId_WhenNoFilesExist_ShouldReturnEmptyList() { Long bookId = 100L; - List entities = Collections.emptyList(); - List expectedFiles = Collections.emptyList(); + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - List result = additionalFileService.getAdditionalFilesByBookId(bookId); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); assertTrue(result.isEmpty()); verify(additionalFileRepository).findByBookId(bookId); @@ -113,34 +112,34 @@ class AdditionalFileServiceTest { @Test void getAdditionalFilesByBookIdAndType_WhenFilesExist_ShouldReturnMappedFiles() { Long bookId = 100L; - AdditionalFileType type = AdditionalFileType.ALTERNATIVE_FORMAT; - List entities = List.of(fileEntity); - List expectedFiles = List.of(additionalFile); + boolean isBook = true; + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); - when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileRepository.findByBookIdAndIsBookFormat(bookId, isBook)).thenReturn(entities); when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); + List result = additionalFileService.getAdditionalFilesByBookIdAndIsBook(bookId, isBook); assertEquals(expectedFiles, result); - verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileRepository).findByBookIdAndIsBookFormat(bookId, isBook); verify(additionalFileMapper).toAdditionalFiles(entities); } @Test void getAdditionalFilesByBookIdAndType_WhenNoFilesExist_ShouldReturnEmptyList() { Long bookId = 100L; - AdditionalFileType type = AdditionalFileType.SUPPLEMENTARY; - List entities = Collections.emptyList(); - List expectedFiles = Collections.emptyList(); + boolean isBook = false; + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); - when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileRepository.findByBookIdAndIsBookFormat(bookId, isBook)).thenReturn(entities); when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); + List result = additionalFileService.getAdditionalFilesByBookIdAndIsBook(bookId, isBook); assertTrue(result.isEmpty()); - verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileRepository).findByBookIdAndIsBookFormat(bookId, isBook); verify(additionalFileMapper).toAdditionalFiles(entities); } @@ -201,7 +200,7 @@ class AdditionalFileServiceTest { @Test void deleteAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() { Long fileId = 1L; - BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + BookFileEntity invalidEntity = new BookFileEntity(); invalidEntity.setId(fileId); when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); @@ -232,7 +231,7 @@ class AdditionalFileServiceTest { void downloadAdditionalFile_WhenPhysicalFileNotExists_ShouldReturnNotFound() throws IOException { Long fileId = 1L; - BookAdditionalFileEntity entityWithNonExistentFile = new BookAdditionalFileEntity(); + BookFileEntity entityWithNonExistentFile = new BookFileEntity(); entityWithNonExistentFile.setId(fileId); entityWithNonExistentFile.setBook(bookEntity); entityWithNonExistentFile.setFileName("non-existent.pdf"); @@ -277,7 +276,7 @@ class AdditionalFileServiceTest { @Test void downloadAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() { Long fileId = 1L; - BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + BookFileEntity invalidEntity = new BookFileEntity(); invalidEntity.setId(fileId); when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java index 0e28a21ed..ada825674 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.model.dto.kobo.KoboBookMetadata; import com.adityachandel.booklore.model.dto.settings.KoboSettings; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.enums.BookFileType; @@ -88,8 +89,12 @@ class KoboEntitlementServiceTest { private BookEntity createCbxBookEntity(Long id) { BookEntity book = new BookEntity(); book.setId(id); - book.setBookType(BookFileType.CBX); - book.setFileSizeKb(1024L); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.CBX); + primaryFile.setFileSizeKb(1024L); + book.setBookFiles(List.of(primaryFile)); BookMetadataEntity metadata = new BookMetadataEntity(); metadata.setTitle("Test CBX Book"); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java index 449fe341e..56304b3c6 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java @@ -103,7 +103,10 @@ class BookServiceTest { void getBooksByIds_returnsMappedBooksWithProgress() { BookEntity entity = new BookEntity(); entity.setId(2L); - entity.setBookType(BookFileType.EPUB); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setBookType(BookFileType.EPUB); + entity.setBookFiles(List.of(primaryFile)); LibraryPathEntity libPath = new LibraryPathEntity(); libPath.setPath("/tmp/library"); LibraryEntity library = new LibraryEntity(); @@ -128,13 +131,16 @@ class BookServiceTest { void getBook_existingBook_returnsBookWithProgress() { BookEntity entity = new BookEntity(); entity.setId(3L); - entity.setBookType(BookFileType.PDF); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setBookType(BookFileType.PDF); + entity.setBookFiles(List.of(primaryFile)); LibraryPathEntity libPath = new LibraryPathEntity(); libPath.setPath("/tmp/library"); LibraryEntity library = new LibraryEntity(); library.setLibraryPaths(List.of(libPath)); entity.setLibrary(library); - when(bookRepository.findById(3L)).thenReturn(Optional.of(entity)); + when(bookRepository.findByIdWithBookFiles(3L)).thenReturn(Optional.of(entity)); when(userBookProgressRepository.findByUserIdAndBookId(anyLong(), eq(3L))).thenReturn(Optional.of(new UserBookProgressEntity())); Book mappedBook = Book.builder().id(3L).bookType(BookFileType.PDF).metadata(BookMetadata.builder().build()).shelves(Set.of()).build(); when(bookMapper.toBook(entity)).thenReturn(mappedBook); @@ -144,13 +150,13 @@ class BookServiceTest { fileUtilsMock.when(() -> FileUtils.getBookFullPath(entity)).thenReturn("/tmp/library/book.pdf"); Book result = bookService.getBook(3L, true); assertEquals(3L, result.getId()); - verify(bookRepository).findById(3L); + verify(bookRepository).findByIdWithBookFiles(3L); } } @Test void getBook_notFound_throwsException() { - when(bookRepository.findById(99L)).thenReturn(Optional.empty()); + when(bookRepository.findByIdWithBookFiles(99L)).thenReturn(Optional.empty()); when(authenticationService.getAuthenticatedUser()).thenReturn(testUser); assertThrows(APIException.class, () -> bookService.getBook(99L, true)); } @@ -159,8 +165,11 @@ class BookServiceTest { void getBookViewerSetting_epub_returnsEpubSettings() { BookEntity entity = new BookEntity(); entity.setId(4L); - entity.setBookType(BookFileType.EPUB); - when(bookRepository.findById(4L)).thenReturn(Optional.of(entity)); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setBookType(BookFileType.EPUB); + entity.setBookFiles(List.of(primaryFile)); + when(bookRepository.findByIdWithBookFiles(4L)).thenReturn(Optional.of(entity)); EpubViewerPreferencesEntity epubPref = new EpubViewerPreferencesEntity(); epubPref.setFont("Arial"); when(epubViewerPreferencesRepository.findByBookIdAndUserId(4L, testUser.getId())).thenReturn(Optional.of(epubPref)); @@ -176,8 +185,11 @@ class BookServiceTest { void getBookViewerSetting_unsupportedType_throwsException() { BookEntity entity = new BookEntity(); entity.setId(5L); - entity.setBookType(null); - when(bookRepository.findById(5L)).thenReturn(Optional.of(entity)); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setBookType(null); + entity.setBookFiles(List.of(primaryFile)); + when(bookRepository.findByIdWithBookFiles(5L)).thenReturn(Optional.of(entity)); when(authenticationService.getAuthenticatedUser()).thenReturn(testUser); assertThrows(APIException.class, () -> bookService.getBookViewerSetting(5L)); } @@ -321,20 +333,24 @@ class BookServiceTest { BookEntity entity = new BookEntity(); entity.setId(11L); LibraryEntity library = new LibraryEntity(); + library.setId(42L); LibraryPathEntity libPath = new LibraryPathEntity(); - libPath.setPath("/tmp/library"); + libPath.setPath("/tmp"); library.setLibraryPaths(List.of(libPath)); entity.setLibrary(library); + entity.setLibraryPath(libPath); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setFileSubPath(""); + primaryFile.setFileName("bookfile.txt"); + entity.setBookFiles(List.of(primaryFile)); + Path filePath = Paths.get("/tmp/bookfile.txt"); Files.createDirectories(filePath.getParent()); Files.write(filePath, "abc".getBytes()); - when(bookQueryService.findAllWithMetadataByIds(Set.of(11L))).thenReturn(List.of(entity)); doNothing().when(bookRepository).deleteAll(anyList()); - BookEntity spyEntity = spy(entity); - doReturn(filePath).when(spyEntity).getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(11L))).thenReturn(List.of(spyEntity)); + when(bookQueryService.findAllWithMetadataByIds(Set.of(11L))).thenReturn(List.of(entity)); BookDeletionResponse response = bookService.deleteBooks(Set.of(11L)).getBody(); @@ -349,15 +365,19 @@ class BookServiceTest { BookEntity entity = new BookEntity(); entity.setId(13L); LibraryEntity library = new LibraryEntity(); + library.setId(42L); LibraryPathEntity libPath = new LibraryPathEntity(); - libPath.setPath("/tmp/library"); + libPath.setPath("/tmp"); library.setLibraryPaths(List.of(libPath)); entity.setLibrary(library); + entity.setLibraryPath(libPath); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(entity); + primaryFile.setFileSubPath(""); + primaryFile.setFileName("nonexistentfile.txt"); + entity.setBookFiles(List.of(primaryFile)); - BookEntity spyEntity = spy(entity); - doReturn(Paths.get("/tmp/nonexistentfile.txt")).when(spyEntity).getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(13L))).thenReturn(List.of(spyEntity)); + when(bookQueryService.findAllWithMetadataByIds(Set.of(13L))).thenReturn(List.of(entity)); doNothing().when(bookRepository).deleteAll(anyList()); BookDeletionResponse response = bookService.deleteBooks(Set.of(13L)).getBody(); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java index 2bbf23689..46885dac1 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java @@ -84,9 +84,12 @@ class BookUpdateServiceTest { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); - book.setBookType(BookFileType.PDF); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.PDF); + book.setBookFiles(List.of(primaryFile)); BookLoreUser user = mock(BookLoreUser.class); - when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); when(authenticationService.getAuthenticatedUser()).thenReturn(user); when(user.getId()).thenReturn(2L); @@ -111,9 +114,12 @@ class BookUpdateServiceTest { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); - book.setBookType(BookFileType.EPUB); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(List.of(primaryFile)); BookLoreUser user = mock(BookLoreUser.class); - when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); when(authenticationService.getAuthenticatedUser()).thenReturn(user); when(user.getId()).thenReturn(2L); @@ -148,8 +154,11 @@ class BookUpdateServiceTest { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); - book.setBookType(null); - when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(null); + book.setBookFiles(List.of(primaryFile)); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); when(authenticationService.getAuthenticatedUser()).thenReturn(mock(BookLoreUser.class)); BookViewerSettings settings = BookViewerSettings.builder().build(); @@ -161,9 +170,12 @@ class BookUpdateServiceTest { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); - book.setBookType(BookFileType.EPUB); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(List.of(primaryFile)); BookLoreUser user = mock(BookLoreUser.class); - when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); when(authenticationService.getAuthenticatedUser()).thenReturn(user); when(user.getId()).thenReturn(2L); @@ -190,9 +202,12 @@ class BookUpdateServiceTest { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); - book.setBookType(BookFileType.PDF); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.PDF); + book.setBookFiles(List.of(primaryFile)); BookLoreUser user = mock(BookLoreUser.class); - when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); when(authenticationService.getAuthenticatedUser()).thenReturn(user); when(user.getId()).thenReturn(2L); @@ -392,8 +407,11 @@ class BookUpdateServiceTest { LibraryPathEntity libraryPath1 = new LibraryPathEntity(); libraryPath1.setPath("/mock/path/1"); doReturn(libraryPath1).when(bookEntity1).getLibraryPath(); - bookEntity1.setFileSubPath("sub1"); - bookEntity1.setFileName("file1.pdf"); + BookFileEntity bookFileEntity1 = new BookFileEntity(); + bookFileEntity1.setBook(bookEntity1); + bookFileEntity1.setFileSubPath("sub1"); + bookFileEntity1.setFileName("file1.pdf"); + bookEntity1.setBookFiles(List.of(bookFileEntity1)); BookEntity bookEntity2 = spy(new BookEntity()); bookEntity2.setId(2L); @@ -401,8 +419,11 @@ class BookUpdateServiceTest { LibraryPathEntity libraryPath2 = new LibraryPathEntity(); libraryPath2.setPath("/mock/path/2"); doReturn(libraryPath2).when(bookEntity2).getLibraryPath(); - bookEntity2.setFileSubPath("sub2"); - bookEntity2.setFileName("file2.pdf"); + BookFileEntity bookFileEntity2 = new BookFileEntity(); + bookFileEntity2.setBook(bookEntity2); + bookFileEntity2.setFileSubPath("sub2"); + bookFileEntity2.setFileName("file2.pdf"); + bookEntity2.setBookFiles(List.of(bookFileEntity2)); when(bookQueryService.findAllWithMetadataByIds(bookIds)).thenReturn(Arrays.asList(bookEntity1, bookEntity2)); ShelfEntity assignShelf = new ShelfEntity(); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceOrderingTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceOrderingTest.java index 37b054f86..b9a0b0d37 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceOrderingTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceOrderingTest.java @@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.dto.Library; 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.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.NotificationService; @@ -41,6 +42,8 @@ class FileMoveServiceOrderingTest { @Mock private BookRepository bookRepository; @Mock + private BookAdditionalFileRepository bookFileRepository; + @Mock private LibraryRepository libraryRepository; @Mock private FileMoveHelper fileMoveHelper; @@ -62,8 +65,8 @@ class FileMoveServiceOrderingTest { // Subclass to mock sleep static class TestableFileMoveService extends FileMoveService { - public TestableFileMoveService(BookRepository bookRepository, LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper, MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper, BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) { - super(bookRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager); + public TestableFileMoveService(BookRepository bookRepository, BookAdditionalFileRepository bookFileRepository, LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper, MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper, BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) { + super(bookRepository, bookFileRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager); } @Override @@ -75,7 +78,7 @@ class FileMoveServiceOrderingTest { @BeforeEach void setUp() throws Exception { fileMoveService = spy(new TestableFileMoveService( - bookRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager)); + bookRepository, bookFileRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager)); LibraryEntity library = new LibraryEntity(); library.setId(42L); @@ -89,14 +92,17 @@ class FileMoveServiceOrderingTest { bookEntity.setId(999L); bookEntity.setLibrary(library); bookEntity.setLibraryPath(libraryPath); - bookEntity.setFileSubPath("SciFi"); - bookEntity.setFileName("Original.epub"); + var primaryFile = new com.adityachandel.booklore.model.entity.BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setFileSubPath("SciFi"); + primaryFile.setFileName("Original.epub"); + bookEntity.setBookFiles(java.util.List.of(primaryFile)); - expectedFilePath = Paths.get(libraryPath.getPath(), bookEntity.getFileSubPath(), "Renamed.epub"); + expectedFilePath = Paths.get(libraryPath.getPath(), primaryFile.getFileSubPath(), "Renamed.epub"); when(fileMoveHelper.getFileNamingPattern(library)).thenReturn("{title}"); when(fileMoveHelper.generateNewFilePath(bookEntity, libraryPath, "{title}")).thenReturn(expectedFilePath); - when(fileMoveHelper.extractSubPath(expectedFilePath, libraryPath)).thenReturn(bookEntity.getFileSubPath()); + when(fileMoveHelper.extractSubPath(expectedFilePath, libraryPath)).thenReturn(primaryFile.getFileSubPath()); doNothing().when(fileMoveHelper).moveFile(any(Path.class), any(Path.class)); doNothing().when(fileMoveHelper).deleteEmptyParentDirsUpToLibraryFolders(any(Path.class), anySet()); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java index b96b65e12..8f58e183e 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java @@ -3,10 +3,15 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.mapper.LibraryMapper; import com.adityachandel.booklore.model.dto.FileMoveResult; +import com.adityachandel.booklore.model.dto.request.FileMoveRequest; import com.adityachandel.booklore.model.dto.Library; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.websocket.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; @@ -17,16 +22,25 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; +import java.util.Collections; +import java.util.Set; +import java.util.List; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -34,11 +48,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class FileMoveServiceTest { @Mock private BookRepository bookRepository; @Mock + private BookAdditionalFileRepository bookFileRepository; + @Mock private LibraryRepository libraryRepository; @Mock private FileMoveHelper fileMoveHelper; @@ -60,8 +77,8 @@ class FileMoveServiceTest { // Subclass to mock sleep for tests static class TestableFileMoveService extends FileMoveService { - public TestableFileMoveService(BookRepository bookRepository, LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper, MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper, BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) { - super(bookRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager); + public TestableFileMoveService(BookRepository bookRepository, BookAdditionalFileRepository bookFileRepository, LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper, MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper, BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) { + super(bookRepository, bookFileRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager); } @Override @@ -74,7 +91,7 @@ class FileMoveServiceTest { void setUp() throws Exception { // Use spy/subclass to avoid actual sleep fileMoveService = spy(new TestableFileMoveService( - bookRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager)); + bookRepository, bookFileRepository, libraryRepository, fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager)); LibraryEntity library = new LibraryEntity(); library.setId(42L); @@ -88,14 +105,20 @@ class FileMoveServiceTest { bookEntity.setId(999L); bookEntity.setLibrary(library); bookEntity.setLibraryPath(libraryPath); - bookEntity.setFileSubPath("SciFi"); - bookEntity.setFileName("Original.epub"); - expectedFilePath = Paths.get(libraryPath.getPath(), bookEntity.getFileSubPath(), "Renamed.epub"); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("SciFi"); + primaryFile.setFileName("Original.epub"); + bookEntity.setBookFiles(new ArrayList<>(List.of(primaryFile))); + + expectedFilePath = Paths.get(libraryPath.getPath(), bookEntity.getPrimaryBookFile().getFileSubPath(), "Renamed.epub"); when(fileMoveHelper.getFileNamingPattern(library)).thenReturn("{title}"); when(fileMoveHelper.generateNewFilePath(bookEntity, libraryPath, "{title}")).thenReturn(expectedFilePath); - when(fileMoveHelper.extractSubPath(expectedFilePath, libraryPath)).thenReturn(bookEntity.getFileSubPath()); + when(fileMoveHelper.extractSubPath(expectedFilePath, libraryPath)).thenReturn(bookEntity.getPrimaryBookFile().getFileSubPath()); doNothing().when(fileMoveHelper).moveFile(any(Path.class), any(Path.class)); doNothing().when(fileMoveHelper).deleteEmptyParentDirsUpToLibraryFolders(any(Path.class), anySet()); } @@ -144,6 +167,102 @@ class FileMoveServiceTest { verify(fileMoveHelper, never()).registerLibraryPaths(anyLong(), any(Path.class)); } + @Test + void bulkMoveFiles_movesAllBookFilesForBook() throws Exception { + LibraryEntity targetLibrary = new LibraryEntity(); + targetLibrary.setId(43L); + + LibraryPathEntity targetLibraryPath = new LibraryPathEntity(); + targetLibraryPath.setId(88L); + targetLibraryPath.setPath("/target/root"); + targetLibraryPath.setLibrary(targetLibrary); + targetLibrary.setLibraryPaths(List.of(targetLibraryPath)); + + BookFileEntity primary = bookEntity.getPrimaryBookFile(); + primary.setId(1L); + + BookFileEntity pdfAlt = new BookFileEntity(); + pdfAlt.setId(2L); + pdfAlt.setBook(bookEntity); + pdfAlt.setBookType(BookFileType.PDF); + pdfAlt.setBookFormat(true); + pdfAlt.setFileSubPath("SciFi"); + pdfAlt.setFileName("Original.pdf"); + + BookFileEntity cover = new BookFileEntity(); + cover.setId(3L); + cover.setBook(bookEntity); + cover.setBookFormat(false); + cover.setFileSubPath("SciFi"); + cover.setFileName("cover.png"); + + bookEntity.setBookFiles(new ArrayList<>(List.of(primary, pdfAlt, cover))); + pdfAlt.setBook(bookEntity); + cover.setBook(bookEntity); + + Path sourcePrimary = Paths.get("/library/root", "SciFi", "Original.epub"); + Path sourcePdfAlt = Paths.get("/library/root", "SciFi", "Original.pdf"); + Path sourceCover = Paths.get("/library/root", "SciFi", "cover.png"); + + Path targetPrimary = Paths.get("/target/root", "SciFi", "Renamed.epub"); + Path targetPdfAlt = Paths.get("/target/root", "SciFi", "Renamed.pdf"); + Path targetCover = Paths.get("/target/root", "SciFi", "cover.png"); + + when(fileMoveHelper.getFileNamingPattern(targetLibrary)).thenReturn("{title}"); + when(fileMoveHelper.generateNewFilePath(bookEntity, targetLibraryPath, "{title}")).thenReturn(targetPrimary); + when(fileMoveHelper.generateNewFilePath(bookEntity, primary, targetLibraryPath, "{title}")).thenReturn(targetPrimary); + when(fileMoveHelper.generateNewFilePath(bookEntity, pdfAlt, targetLibraryPath, "{title}")).thenReturn(targetPdfAlt); + when(fileMoveHelper.extractSubPath(targetPrimary, targetLibraryPath)).thenReturn("SciFi"); + + when(bookRepository.findByIdWithBookFiles(bookEntity.getId())).thenReturn(Optional.of(bookEntity)); + + when(fileMoveHelper.moveFileWithBackup(sourcePrimary)).thenReturn(sourcePrimary.resolveSibling("Original.epub.tmp_move")); + when(fileMoveHelper.moveFileWithBackup(sourcePdfAlt)).thenReturn(sourcePdfAlt.resolveSibling("Original.pdf.tmp_move")); + when(fileMoveHelper.moveFileWithBackup(sourceCover)).thenReturn(sourceCover.resolveSibling("cover.png.tmp_move")); + + doNothing().when(fileMoveHelper).commitMove(any(Path.class), any(Path.class)); + doNothing().when(entityManager).clear(); + when(bookMapper.toBookWithDescription(any(BookEntity.class), eq(false))).thenReturn(null); + + Set affected = new HashSet<>(); + affected.add(42L); + affected.add(43L); + + when(monitoringRegistrationService.getPathsForLibraries(anySet())).thenReturn(Set.of(Paths.get("/library/root"), Paths.get("/target/root"))); + doNothing().when(monitoringRegistrationService).unregisterLibraries(anySet()); + when(monitoringRegistrationService.waitForEventsDrainedByPaths(anySet(), anyLong())).thenReturn(true); + + when(bookRepository.findById(anyLong())).thenReturn(Optional.of(bookEntity)); + when(libraryRepository.findById(anyLong())).thenReturn(Optional.of(targetLibrary)); + when(libraryMapper.toLibrary(any(LibraryEntity.class))).thenReturn(null); + doNothing().when(monitoringRegistrationService).registerLibrary(any()); + + FileMoveRequest.Move m = new FileMoveRequest.Move(); + m.setBookId(bookEntity.getId()); + m.setTargetLibraryId(targetLibrary.getId()); + m.setTargetLibraryPathId(targetLibraryPath.getId()); + FileMoveRequest req = new FileMoveRequest(); + req.setMoves(List.of(m)); + + fileMoveService.bulkMoveFiles(req); + + verify(fileMoveHelper).moveFileWithBackup(sourcePrimary); + verify(fileMoveHelper).moveFileWithBackup(sourcePdfAlt); + verify(fileMoveHelper).moveFileWithBackup(sourceCover); + + verify(fileMoveHelper).commitMove(any(Path.class), eq(targetPrimary)); + verify(fileMoveHelper).commitMove(any(Path.class), eq(targetPdfAlt)); + verify(fileMoveHelper).commitMove(any(Path.class), eq(targetCover)); + + verify(bookFileRepository).updateFileNameAndSubPath(eq(primary.getId()), any(String.class), any(String.class)); + verify(bookFileRepository).updateFileNameAndSubPath(eq(pdfAlt.getId()), any(String.class), any(String.class)); + verify(bookFileRepository).updateFileNameAndSubPath(eq(cover.getId()), any(String.class), any(String.class)); + verify(bookRepository).updateLibrary(eq(bookEntity.getId()), eq(targetLibrary.getId()), eq(targetLibraryPath)); + verify(entityManager).clear(); + + verify(notificationService).sendMessage(eq(Topic.BOOK_UPDATE), any()); + } + @Test void moveSingleFile_whenLibraryNotMonitoredStatusButHasPaths_triggersUnregister() throws Exception { when(monitoringRegistrationService.isLibraryMonitored(42L)).thenReturn(false); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessorTest.java index be837705e..f84b589b7 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessorTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessorTest.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.service.fileprocessor; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; @@ -75,8 +76,13 @@ class CbxProcessorTest { BookEntity bookEntity = new BookEntity(); bookEntity.setId(1L); - bookEntity.setFileName(zipAsCbr.getName()); - bookEntity.setFileSubPath(""); + + // Create and configure the primary book file + BookFileEntity bookFile = new BookFileEntity(); + bookFile.setFileName(zipAsCbr.getName()); + bookFile.setFileSubPath(""); + bookFile.setBook(bookEntity); + bookEntity.getBookFiles().add(bookFile); LibraryPathEntity libPath = new LibraryPathEntity(); libPath.setPath(tempDir.toString()); bookEntity.setLibraryPath(libPath); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java index 565c179e0..beea692fd 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.service.kobo; import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.KoboSettings; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -13,6 +14,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -172,7 +175,11 @@ class KoboCompatibilityServiceTest { BookEntity bookWithNullType = new BookEntity(); bookWithNullType.setId(1L); - bookWithNullType.setBookType(null); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookWithNullType); + primaryFile.setBookType(null); + bookWithNullType.setBookFiles(List.of(primaryFile)); boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(bookWithNullType); @@ -289,8 +296,13 @@ class KoboCompatibilityServiceTest { private BookEntity createBookEntity(Long id, BookFileType bookType, Long fileSizeKb) { BookEntity book = new BookEntity(); book.setId(id); - book.setBookType(bookType); - book.setFileSizeKb(fileSizeKb); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setBookType(bookType); + primaryFile.setFileSizeKb(fileSizeKb); + book.setBookFiles(List.of(primaryFile)); + return book; } } \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotServiceTest.java index fa968e485..39fc83555 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotServiceTest.java @@ -5,6 +5,7 @@ import com.adityachandel.booklore.mapper.BookEntityToKoboSnapshotBookMapper; import com.adityachandel.booklore.model.dto.BookLoreUser; import com.adityachandel.booklore.model.dto.BookLoreUser.UserPermissions; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity; import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; @@ -83,11 +84,19 @@ class KoboLibrarySnapshotServiceTest { .library(ownersLibrary) .build(); + BookFileEntity ownersPrimaryFile = new BookFileEntity(); + ownersPrimaryFile.setBook(ownersBook); + ownersBook.setBookFiles(List.of(ownersPrimaryFile)); + otherUsersBook = BookEntity.builder() .id(202L) .library(othersLibrary) .build(); + BookFileEntity otherPrimaryFile = new BookFileEntity(); + otherPrimaryFile.setBook(otherUsersBook); + otherUsersBook.setBookFiles(List.of(otherPrimaryFile)); + UserPermissions userPermissions = new UserPermissions(); userPermissions.setAdmin(false); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorExampleTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorExampleTest.java index c91219da6..2308d1dce 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorExampleTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorExampleTest.java @@ -1,6 +1,6 @@ package com.adityachandel.booklore.service.library; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; @@ -46,7 +46,7 @@ class FolderAsBookFileProcessorExampleTest { private FolderAsBookFileProcessor processor; @Captor - private ArgumentCaptor additionalFileCaptor; + private ArgumentCaptor additionalFileCaptor; private MockedStatic fileUtilsMock; private MockedStatic fileFingerprintMock; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java index fbd1d0508..4fabeafef 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/FolderAsBookFileProcessorTest.java @@ -4,7 +4,6 @@ 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; @@ -58,7 +57,7 @@ class FolderAsBookFileProcessorTest { private FolderAsBookFileProcessor processor; @Captor - private ArgumentCaptor additionalFileCaptor; + private ArgumentCaptor additionalFileCaptor; private MockedStatic fileUtilsMock; private MockedStatic fileFingerprintMock; @@ -134,12 +133,12 @@ class FolderAsBookFileProcessorTest { verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString()); verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture()); - List capturedFiles = additionalFileCaptor.getAllValues(); + List capturedFiles = additionalFileCaptor.getAllValues(); assertThat(capturedFiles).hasSize(2); - assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName) + assertThat(capturedFiles).extracting(BookFileEntity::getFileName) .containsExactlyInAnyOrder("book.epub", "cover.jpg"); - assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getAdditionalFileType) - .containsExactly(AdditionalFileType.ALTERNATIVE_FORMAT, AdditionalFileType.SUPPLEMENTARY); + assertThat(capturedFiles).extracting(BookFileEntity::isBookFormat) + .containsExactlyInAnyOrder(true, false); } @Test @@ -167,9 +166,9 @@ class FolderAsBookFileProcessorTest { verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString()); verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture()); - List capturedFiles = additionalFileCaptor.getAllValues(); + List capturedFiles = additionalFileCaptor.getAllValues(); assertThat(capturedFiles).hasSize(2); - assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName) + assertThat(capturedFiles).extracting(BookFileEntity::getFileName) .containsExactlyInAnyOrder("book.epub", "cover.jpg"); } @@ -200,10 +199,10 @@ class FolderAsBookFileProcessorTest { verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString()); verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture()); - List capturedFiles = additionalFileCaptor.getAllValues(); + List capturedFiles = additionalFileCaptor.getAllValues(); assertThat(capturedFiles).hasSize(2); - assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getAdditionalFileType) - .containsOnly(AdditionalFileType.SUPPLEMENTARY); + assertThat(capturedFiles).extracting(BookFileEntity::isBookFormat) + .containsOnly(false); } @Test @@ -246,8 +245,8 @@ class FolderAsBookFileProcessorTest { verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString()); verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture()); - List capturedFiles = additionalFileCaptor.getAllValues(); - assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName) + List capturedFiles = additionalFileCaptor.getAllValues(); + assertThat(capturedFiles).extracting(BookFileEntity::getFileName) .containsExactlyInAnyOrder("book.pdf", "book.cbz"); } @@ -301,12 +300,13 @@ class FolderAsBookFileProcessorTest { .toList(); BookEntity existingBook = createBookEntity(1L, "book.pdf", "books"); - BookAdditionalFileEntity existingAdditionalFile = BookAdditionalFileEntity.builder() + BookFileEntity existingAdditionalFile = BookFileEntity.builder() .id(1L) .book(existingBook) .fileName("book.epub") .fileSubPath("books") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) + .isBookFormat(true) + .bookType(BookFileType.EPUB) .build(); when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), eq("books"))) @@ -324,7 +324,7 @@ class FolderAsBookFileProcessorTest { verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString()); verify(bookAdditionalFileRepository, times(1)).save(additionalFileCaptor.capture()); - BookAdditionalFileEntity capturedFile = additionalFileCaptor.getValue(); + BookFileEntity capturedFile = additionalFileCaptor.getValue(); assertThat(capturedFile.getFileName()).isEqualTo("cover.jpg"); } @@ -418,16 +418,22 @@ class FolderAsBookFileProcessorTest { private BookEntity createBookEntity(Long id, String fileName, String subPath) { BookEntity book = new BookEntity(); book.setId(id); - book.setFileName(fileName); - book.setFileSubPath(subPath); - book.setBookType(BookFileType.PDF); book.setAddedOn(Instant.parse("2025-01-01T12:00:00Z")); + book.setBookFiles(new ArrayList<>()); + LibraryPathEntity libraryPath = new LibraryPathEntity(); libraryPath.setId(1L); libraryPath.setPath("/test/library"); book.setLibraryPath(libraryPath); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setFileName(fileName); + primaryFile.setFileSubPath(subPath); + primaryFile.setBookType(BookFileType.PDF); + book.getBookFiles().add(primaryFile); + return book; } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryProcessingServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryProcessingServiceTest.java index 7aad84a76..666a93443 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryProcessingServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryProcessingServiceTest.java @@ -1,14 +1,15 @@ 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.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.model.enums.LibraryScanMode; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.task.options.RescanLibraryContext; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,6 +24,7 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -77,8 +79,11 @@ class LibraryProcessingServiceTest { BookEntity existingBook = new BookEntity(); existingBook.setLibraryPath(pathEntity); - existingBook.setFileSubPath(""); - existingBook.setFileName("book1.epub"); + BookFileEntity existingBookFile = new BookFileEntity(); + existingBookFile.setBook(existingBook); + existingBook.setBookFiles(List.of(existingBookFile)); + existingBook.getPrimaryBookFile().setFileSubPath(""); + existingBook.getPrimaryBookFile().setFileName("book1.epub"); libraryEntity.setBookEntities(List.of(existingBook)); when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity)); @@ -127,8 +132,11 @@ class LibraryProcessingServiceTest { BookEntity existingBook = new BookEntity(); existingBook.setLibraryPath(pathEntity); - existingBook.setFileSubPath(""); - existingBook.setFileName("book1.epub"); + BookFileEntity existingBookFile = new BookFileEntity(); + existingBookFile.setBook(existingBook); + existingBook.setBookFiles(List.of(existingBookFile)); + existingBook.getPrimaryBookFile().setFileSubPath(""); + existingBook.getPrimaryBookFile().setFileName("book1.epub"); libraryEntity.setBookEntities(List.of(existingBook)); when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity)); @@ -256,7 +264,7 @@ class LibraryProcessingServiceTest { BookEntity parentBook = new BookEntity(); parentBook.setLibraryPath(pathEntity); - BookAdditionalFileEntity additionalFileEntity = new BookAdditionalFileEntity(); + BookFileEntity additionalFileEntity = new BookFileEntity(); additionalFileEntity.setBook(parentBook); // Links to library path additionalFileEntity.setFileSubPath(""); additionalFileEntity.setFileName("extra.pdf"); @@ -271,4 +279,73 @@ class LibraryProcessingServiceTest { assertThat(captor.getValue()).isEmpty(); } + + @Test + void rescanLibrary_shouldNotDeleteNonBookFiles_whenProcessorDoesNotSupportSupplementaryFiles() throws IOException { + long libraryId = 1L; + + LibraryEntity libraryEntity = new LibraryEntity(); + libraryEntity.setId(libraryId); + libraryEntity.setName("Test Library"); + libraryEntity.setScanMode(LibraryScanMode.FILE_AS_BOOK); + + LibraryPathEntity pathEntity = new LibraryPathEntity(); + pathEntity.setId(10L); + pathEntity.setPath("/library"); + libraryEntity.setLibraryPaths(List.of(pathEntity)); + + BookEntity book = new BookEntity(); + book.setId(11L); + book.setLibrary(libraryEntity); + book.setLibraryPath(pathEntity); + + BookFileEntity epub = new BookFileEntity(); + epub.setId(1L); + epub.setBook(book); + epub.setFileSubPath("author/title"); + epub.setFileName("book.epub"); + epub.setBookFormat(true); + + BookFileEntity pdf = new BookFileEntity(); + pdf.setId(2L); + pdf.setBook(book); + pdf.setFileSubPath("author/title"); + pdf.setFileName("book.pdf"); + pdf.setBookFormat(true); + + BookFileEntity image = new BookFileEntity(); + image.setId(3L); + image.setBook(book); + image.setFileSubPath("author/title"); + image.setFileName("image.png"); + image.setBookFormat(false); + + book.setBookFiles(List.of(epub, pdf, image)); + libraryEntity.setBookEntities(List.of(book)); + + when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity)); + when(fileProcessorRegistry.getProcessor(libraryEntity)).thenReturn(libraryFileProcessor); + when(libraryFileProcessor.supportsSupplementaryFiles()).thenReturn(false); + + LibraryFile epubOnDisk = LibraryFile.builder() + .libraryEntity(libraryEntity) + .libraryPathEntity(pathEntity) + .fileSubPath("author/title") + .fileName("book.epub") + .build(); + + LibraryFile pdfOnDisk = LibraryFile.builder() + .libraryEntity(libraryEntity) + .libraryPathEntity(pathEntity) + .fileSubPath("author/title") + .fileName("book.pdf") + .build(); + + when(libraryFileHelper.getLibraryFiles(libraryEntity, libraryFileProcessor)).thenReturn(List.of(epubOnDisk, pdfOnDisk)); + when(bookAdditionalFileRepository.findByLibraryId(libraryId)).thenReturn(List.of(epub, pdf, image)); + + libraryProcessingService.rescanLibrary(RescanLibraryContext.builder().libraryId(libraryId).build()); + + verify(bookDeletionService, never()).deleteRemovedAdditionalFiles(any()); + } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryRescanHelperTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryRescanHelperTest.java index 7c3ae8476..35592dc07 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryRescanHelperTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/library/LibraryRescanHelperTest.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.service.library; import com.adityachandel.booklore.model.MetadataUpdateContext; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; @@ -310,11 +311,15 @@ class LibraryRescanHelperTest { libraryPath.setPath("/test/path"); BookEntity book = new BookEntity(); book.setId(id); - book.setFileName(fileName); - book.setBookType(bookType); book.setDeleted(false); book.setLibraryPath(libraryPath); - book.setFileSubPath(""); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setFileName(fileName); + primaryFile.setFileSubPath(""); + primaryFile.setBookType(bookType); + book.setBookFiles(List.of(primaryFile)); return book; } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java index d75a0f277..9369520c0 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java @@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.BookRepository; @@ -65,7 +66,10 @@ class BookCoverServiceTest { BookEntity book = new BookEntity(); book.setId(id); book.setMetadata(metadata); - book.setBookType(BookFileType.EPUB); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + book.setBookFiles(List.of(primaryFile)); + book.getPrimaryBookFile().setBookType(BookFileType.EPUB); return book; } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterCategoryTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterCategoryTest.java index 943ebe18c..1b1f676fc 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterCategoryTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterCategoryTest.java @@ -7,8 +7,10 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.entity.CategoryEntity; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.MetadataReplaceMode; import com.adityachandel.booklore.repository.*; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -24,6 +26,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -389,8 +392,17 @@ class BookMetadataUpdaterCategoryTest { } metadataEntity.setCategories(categories); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + return bookEntity; } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterTest.java index 33218b3bc..b0dbcd5d5 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdaterTest.java @@ -6,9 +6,11 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.entity.MoodEntity; import com.adityachandel.booklore.model.entity.TagEntity; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.MetadataReplaceMode; import com.adityachandel.booklore.repository.*; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -25,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -83,8 +86,17 @@ class BookMetadataUpdaterTest { existingTags.add(TagEntity.builder().name("Tag1").build()); existingTags.add(TagEntity.builder().name("Tag2").build()); metadataEntity.setTags(existingTags); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setTags(Set.of("Tag1")); @@ -118,8 +130,17 @@ class BookMetadataUpdaterTest { existingTags.add(TagEntity.builder().name("Tag1").build()); existingTags.add(TagEntity.builder().name("Tag2").build()); metadataEntity.setTags(existingTags); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setTags(Collections.emptySet()); @@ -149,8 +170,17 @@ class BookMetadataUpdaterTest { existingTags.add(TagEntity.builder().name("Tag1").build()); existingTags.add(TagEntity.builder().name("Tag2").build()); metadataEntity.setTags(existingTags); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setTags(Set.of("Tag3")); @@ -187,8 +217,17 @@ class BookMetadataUpdaterTest { existingMoods.add(MoodEntity.builder().name("Mood1").build()); existingMoods.add(MoodEntity.builder().name("Mood2").build()); metadataEntity.setMoods(existingMoods); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setMoods(Set.of("Mood1")); @@ -222,8 +261,17 @@ class BookMetadataUpdaterTest { existingMoods.add(MoodEntity.builder().name("Mood1").build()); existingMoods.add(MoodEntity.builder().name("Mood2").build()); metadataEntity.setMoods(existingMoods); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setMoods(Collections.emptySet()); @@ -252,8 +300,17 @@ class BookMetadataUpdaterTest { existingMoods.add(MoodEntity.builder().name("Mood1").build()); existingMoods.add(MoodEntity.builder().name("Mood2").build()); metadataEntity.setMoods(existingMoods); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setMoods(Set.of("Mood3")); @@ -286,8 +343,17 @@ class BookMetadataUpdaterTest { BookMetadataEntity metadataEntity = new BookMetadataEntity(); metadataEntity.setTitle("Old Title"); metadataEntity.setTitleLocked(false); + metadataEntity.setBook(bookEntity); bookEntity.setMetadata(metadataEntity); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + primaryFile.setBookType(BookFileType.EPUB); + primaryFile.setBookFormat(true); + primaryFile.setFileSubPath("sub"); + primaryFile.setFileName("file.epub"); + bookEntity.setBookFiles(List.of(primaryFile)); + BookMetadata newMetadata = new BookMetadata(); newMetadata.setTitle("New Title"); newMetadata.setTitleLocked(true); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/EpubMetadataWriterTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/EpubMetadataWriterTest.java index 1dc62049b..b5d894929 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/EpubMetadataWriterTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/EpubMetadataWriterTest.java @@ -5,6 +5,7 @@ import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.service.appsettings.AppSettingService; @@ -68,8 +69,11 @@ class EpubMetadataWriterTest { LibraryPathEntity libraryPath = new LibraryPathEntity(); libraryPath.setPath(tempDir.toString()); bookEntity.setLibraryPath(libraryPath); - bookEntity.setFileSubPath(""); - bookEntity.setFileName("test.epub"); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(bookEntity); + bookEntity.setBookFiles(Collections.singletonList(primaryFile)); + bookEntity.getPrimaryBookFile().setFileSubPath(""); + bookEntity.getPrimaryBookFile().setFileName("test.epub"); } @Nested diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/reader/CbxReaderServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/reader/CbxReaderServiceTest.java index 6a7c30117..65580d272 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/reader/CbxReaderServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/reader/CbxReaderServiceTest.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.service.reader; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.util.FileUtils; import com.github.junrar.Archive; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java index f82b02f82..214b7e4fd 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java @@ -4,13 +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.AdditionalFileMapper; -import com.adityachandel.booklore.model.dto.AdditionalFile; +import com.adityachandel.booklore.model.dto.BookFile; import com.adityachandel.booklore.model.dto.settings.AppSettings; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookFileEntity; 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.BookFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; @@ -20,6 +20,7 @@ import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.enums.BookFileExtension; import com.adityachandel.booklore.service.metadata.extractor.MetadataExtractorFactory; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -36,6 +37,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; @@ -72,6 +74,9 @@ class FileUploadServiceTest { @Mock AdditionalFileMapper additionalFileMapper; + @Mock + MonitoringRegistrationService monitoringRegistrationService; + AppProperties appProperties; FileUploadService service; @@ -88,7 +93,7 @@ class FileUploadServiceTest { service = new FileUploadService( libraryRepository, bookRepository, bookAdditionalFileRepository, - appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper + appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper, monitoringRegistrationService ); } @@ -228,7 +233,13 @@ class FileUploadServiceTest { BookEntity book = new BookEntity(); book.setId(bookId); book.setLibraryPath(libPath); - book.setFileSubPath("."); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setFileName("primary.epub"); + primaryFile.setFileSubPath("."); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(new ArrayList<>(List.of(primaryFile))); when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); @@ -237,20 +248,20 @@ class FileUploadServiceTest { when(bookAdditionalFileRepository.findByAltFormatCurrentHash("hash-123")).thenReturn(Optional.empty()); - when(bookAdditionalFileRepository.save(any(BookAdditionalFileEntity.class))).thenAnswer(inv -> { - BookAdditionalFileEntity e = inv.getArgument(0); + when(bookAdditionalFileRepository.save(any(BookFileEntity.class))).thenAnswer(inv -> { + BookFileEntity e = inv.getArgument(0); e.setId(99L); return e; }); - AdditionalFile dto = mock(AdditionalFile.class); - when(additionalFileMapper.toAdditionalFile(any(BookAdditionalFileEntity.class))).thenReturn(dto); + BookFile dto = mock(BookFile.class); + when(additionalFileMapper.toAdditionalFile(any(BookFileEntity.class))).thenReturn(dto); - AdditionalFile result = service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, "desc"); + BookFile result = service.uploadAdditionalFile(bookId, file, true, BookFileType.PDF, "desc"); assertThat(result).isEqualTo(dto); - verify(bookAdditionalFileRepository).save(any(BookAdditionalFileEntity.class)); - verify(additionalFileMapper).toAdditionalFile(any(BookAdditionalFileEntity.class)); + verify(bookAdditionalFileRepository).save(any(BookFileEntity.class)); + verify(additionalFileMapper).toAdditionalFile(any(BookFileEntity.class)); } } @@ -265,19 +276,25 @@ class FileUploadServiceTest { BookEntity book = new BookEntity(); book.setId(bookId); book.setLibraryPath(libPath); - book.setFileSubPath("."); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setFileName("primary.epub"); + primaryFile.setFileSubPath("."); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(new ArrayList<>(List.of(primaryFile))); when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); try (MockedStatic fp = mockStatic(FileFingerprint.class)) { fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("dup-hash"); - BookAdditionalFileEntity existing = new BookAdditionalFileEntity(); + BookFileEntity existing = new BookFileEntity(); existing.setId(1L); when(bookAdditionalFileRepository.findByAltFormatCurrentHash("dup-hash")).thenReturn(Optional.of(existing)); assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, null)); + .isThrownBy(() -> service.uploadAdditionalFile(bookId, file, true, BookFileType.PDF, null)); } } @@ -338,16 +355,19 @@ class FileUploadServiceTest { BookEntity book = new BookEntity(); book.setId(bookId); book.setLibraryPath(libPath); - book.setFileSubPath("."); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + book.setBookFiles(new ArrayList<>(List.of(primaryFile))); + book.getPrimaryBookFile().setFileSubPath("."); when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); - when(bookAdditionalFileRepository.save(any(BookAdditionalFileEntity.class))).thenAnswer(inv -> inv.getArgument(0)); - when(additionalFileMapper.toAdditionalFile(any(BookAdditionalFileEntity.class))).thenReturn(mock(AdditionalFile.class)); + when(bookAdditionalFileRepository.save(any(BookFileEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + when(additionalFileMapper.toAdditionalFile(any(BookFileEntity.class))).thenReturn(mock(BookFile.class)); try (MockedStatic fp = mockStatic(FileFingerprint.class)) { fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash"); - service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, "desc"); + service.uploadAdditionalFile(bookId, file, true, BookFileType.PDF, "desc"); File[] files = tempDir.toFile().listFiles(); assertThat(files).isNotNull(); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileUtilsTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileUtilsTest.java index d1459a6d8..94448f0f7 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileUtilsTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileUtilsTest.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.util; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import org.junit.jupiter.api.Test; @@ -8,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -22,8 +24,12 @@ class FileUtilsTest { BookEntity bookEntity = new BookEntity(); bookEntity.setLibraryPath(libraryPathEntity); - bookEntity.setFileSubPath(subPath); - bookEntity.setFileName(fileName); + + BookFileEntity bookFileEntity = new BookFileEntity(); + bookFileEntity.setBook(bookEntity); + bookFileEntity.setFileSubPath(subPath); + bookFileEntity.setFileName(fileName); + bookEntity.setBookFiles(List.of(bookFileEntity)); return bookEntity; } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/PathPatternResolverTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/PathPatternResolverTest.java index 67b2f0d7b..8729ea911 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/PathPatternResolverTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/PathPatternResolverTest.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore.util; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import org.junit.jupiter.api.DisplayName; @@ -221,7 +222,12 @@ class PathPatternResolverTest { metadata.setTitle("Book Title"); BookEntity book = new BookEntity(); - book.setFileName("book.epub"); + + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setBook(book); + primaryFile.setFileName("book.epub"); + primaryFile.setFileSubPath(""); + book.setBookFiles(List.of(primaryFile)); book.setMetadata(metadata); String result = PathPatternResolver.resolvePattern(book, "{title}.{extension}"); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java index 8c3a3b4ce..ffe905f1e 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java @@ -51,7 +51,7 @@ public class LibraryTestBuilder { private final Map libraryFileHashes = new HashMap<>(); private final Map bookRepository = new HashMap<>(); private final Map bookMap = new HashMap<>(); - private final Map bookAdditionalFileRepository = new HashMap<>(); + private final Map bookAdditionalFileRepository = new HashMap<>(); public LibraryTestBuilder(MockedStatic fileUtilsMock, MockedStatic fileFingerprintMock, @@ -85,15 +85,15 @@ public class LibraryTestBuilder { return bookRepository.values() .stream() .filter(book -> book.getLibraryPath().getId().equals(libraryPathId) && - book.getFileSubPath().startsWith(fileSubPath)) + book.getPrimaryBookFile().getFileSubPath().startsWith(fileSubPath)) .toList(); }); // lenient is used to avoid strict stubbing issues, // the builder does not know when the save method will be called - lenient().when(bookAdditionalFileRepositoryMock.save(any(BookAdditionalFileEntity.class))) + lenient().when(bookAdditionalFileRepositoryMock.save(any(BookFileEntity.class))) .thenAnswer(invocation -> { - BookAdditionalFileEntity additionalFile = invocation.getArgument(0); + BookFileEntity additionalFile = invocation.getArgument(0); return saveBookAdditionalFile(additionalFile); }); } @@ -154,7 +154,7 @@ public class LibraryTestBuilder { return bookMap.get(bookTitle); } - public List getBookAdditionalFiles() { + public List getBookAdditionalFiles() { return new ArrayList<>(bookAdditionalFileRepository.values()); } @@ -192,20 +192,28 @@ public class LibraryTestBuilder { .build(); String hash = computeFileHash(Path.of(subPath, fileName)); + BookEntity bookEntity = BookEntity.builder() .id(id) - .fileName(fileName) - .fileSubPath(subPath) - .bookType(getBookFileType(fileName)) - .fileSizeKb(1024L) .library(getLibraryEntity()) .libraryPath(getLibraryEntity().getLibraryPaths().getLast()) .addedOn(java.time.Instant.now()) + .metadata(metadata) + .bookFiles(new ArrayList<>()) + .build(); + + BookFileEntity primaryFile = BookFileEntity.builder() + .book(bookEntity) + .fileName(fileName) + .fileSubPath(subPath) + .isBookFormat(true) + .bookType(getBookFileType(fileName)) + .fileSizeKb(1024L) .initialHash(hash) .currentHash(hash) - .metadata(metadata) - .additionalFiles(new ArrayList<>()) + .addedOn(java.time.Instant.now()) .build(); + bookEntity.getBookFiles().add(primaryFile); bookRepository.put(bookEntity.getId(), bookEntity); bookMap.put(metadata.getTitle(), bookEntity); @@ -288,20 +296,28 @@ public class LibraryTestBuilder { .title(FilenameUtils.removeExtension(libraryFile.getFileName())) .bookId(id) .build(); + BookEntity bookEntity = BookEntity.builder() .id(id) // Simple ID generation based on index - .fileName(libraryFile.getFileName()) - .fileSubPath(libraryFile.getFileSubPath()) - .bookType(libraryFile.getBookFileType()) - .fileSizeKb(1024L) .library(libraryFile.getLibraryPathEntity().getLibrary()) .libraryPath(libraryFile.getLibraryPathEntity()) .addedOn(java.time.Instant.now()) + .metadata(metadata) + .bookFiles(new ArrayList<>()) + .build(); + + BookFileEntity primaryFile = BookFileEntity.builder() + .book(bookEntity) + .fileName(libraryFile.getFileName()) + .fileSubPath(libraryFile.getFileSubPath()) + .isBookFormat(true) + .bookType(libraryFile.getBookFileType()) + .fileSizeKb(1024L) .initialHash(hash) .currentHash(hash) - .metadata(metadata) - .additionalFiles(new ArrayList<>()) + .addedOn(java.time.Instant.now()) .build(); + bookEntity.getBookFiles().add(primaryFile); bookRepository.put(bookEntity.getId(), bookEntity); bookMap.put(metadata.getTitle(), bookEntity); @@ -316,13 +332,13 @@ public class LibraryTestBuilder { return bookRepository.get(bookId); } - private @NotNull BookAdditionalFileEntity saveBookAdditionalFile(BookAdditionalFileEntity additionalFile) { + private @NotNull BookFileEntity saveBookAdditionalFile(BookFileEntity additionalFile) { if (additionalFile.getId() != null) { throw new IllegalArgumentException("ID must be null for new additional files"); } // Do not allow files with duplicate hashes for alternative formats only - if (additionalFile.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT && + if (additionalFile.isBookFormat() && bookAdditionalFileRepository.values() .stream() .anyMatch(existingFile -> existingFile.getCurrentHash() diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilderAssert.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilderAssert.java index 8c85468fb..e605992f0 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilderAssert.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilderAssert.java @@ -1,13 +1,12 @@ package com.adityachandel.booklore.util.builder; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.enums.AdditionalFileType; -import com.adityachandel.booklore.model.enums.BookFileExtension; +import com.adityachandel.booklore.model.entity.BookFileEntity; import com.adityachandel.booklore.model.enums.BookFileType; import org.assertj.core.api.AbstractAssert; import org.assertj.core.api.Assertions; -import java.util.Optional; +import java.util.List; +import java.util.stream.Collectors; public class LibraryTestBuilderAssert extends AbstractAssert { @@ -41,14 +40,15 @@ public class LibraryTestBuilderAssert extends AbstractAssert a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT) - .map(BookAdditionalFileEntity::getFileName) - .map(BookFileExtension::fromFileName) - .filter(Optional::isPresent) - .map(Optional::get) - .map(BookFileExtension::getType)) + List additionalFormatTypesActual = book.getBookFiles() + .stream() + .filter(BookFileEntity::isBookFormat) + .filter(a -> !a.equals(book.getPrimaryBookFile())) + .map(BookFileEntity::getBookType) + .filter(a -> a != null) + .collect(Collectors.toList()); + + Assertions.assertThat(additionalFormatTypesActual) .describedAs("Book '%s' should have additional formats: %s", bookTitle, additionalFormatTypes) .containsExactlyInAnyOrder(additionalFormatTypes); @@ -61,17 +61,17 @@ public class LibraryTestBuilderAssert extends AbstractAssert a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY) - .map(BookAdditionalFileEntity::getFileName)) + .filter(a -> !a.isBookFormat()) + .map(BookFileEntity::getFileName)) .describedAs("Book '%s' should have supplementary files", bookTitle) .containsExactlyInAnyOrder(supplementaryFiles); var additionalFiles = actual.getBookAdditionalFiles(); Assertions.assertThat(additionalFiles) .describedAs("Book '%s' should have supplementary files", bookTitle) - .anyMatch(a -> a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY && + .anyMatch(a -> !a.isBookFormat() && a.getBook().getId().equals(book.getId()) && a.getFileName().equals(supplementaryFiles[0])); @@ -84,9 +84,15 @@ public class LibraryTestBuilderAssert extends AbstractAssert a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT)) + List additionalFormatTypesActual = book.getBookFiles() + .stream() + .filter(BookFileEntity::isBookFormat) + .filter(a -> !a.equals(book.getPrimaryBookFile())) + .map(BookFileEntity::getBookType) + .filter(a -> a != null) + .collect(Collectors.toList()); + + Assertions.assertThat(additionalFormatTypesActual) .describedAs("Book '%s' should have no additional formats", bookTitle) .isEmpty(); @@ -99,9 +105,9 @@ public class LibraryTestBuilderAssert extends AbstractAssert a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY); + .noneMatch(a -> !a.isBookFormat()); return this; } @@ -112,9 +118,10 @@ public class LibraryTestBuilderAssert extends AbstractAssert { const formData = new FormData(); formData.append('file', file); - formData.append('additionalFileType', fileType); + + const isBook = fileType === AdditionalFileType.ALTERNATIVE_FORMAT; + formData.append('isBook', String(isBook)); + + if (isBook) { + const lower = (file?.name || '').toLowerCase(); + const ext = lower.includes('.') ? lower.substring(lower.lastIndexOf('.') + 1) : ''; + const bookType = ext === 'pdf' + ? 'PDF' + : ext === 'epub' + ? 'EPUB' + : (ext === 'cbz' || ext === 'cbr' || ext === 'cb7' || ext === 'cbt') + ? 'CBX' + : null; + + if (bookType) { + formData.append('bookType', bookType); + } + } if (description) { formData.append('description', description); } @@ -378,8 +396,11 @@ export class BookService { } downloadAdditionalFile(book: Book, fileId: number): void { - const additionalFile = book.alternativeFormats!.find((f: AdditionalFile) => f.id === fileId); - const downloadUrl = `${this.url}/${additionalFile!.id}/files/${fileId}/download`; + const additionalFile = [ + ...(book.alternativeFormats || []), + ...(book.supplementaryFiles || []) + ].find((f: AdditionalFile) => f.id === fileId); + const downloadUrl = `${this.url}/${book!.id}/files/${fileId}/download`; this.fileDownloadService.downloadFile(downloadUrl, additionalFile!.fileName!); } diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index 841fdec00..1bee07de1 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -19,6 +19,7 @@ services: - './booklore-api:/booklore-api' - ./shared/data:/app/data - ./shared/books:/books + - ./shared/bookdrop:/bookdrop backend_db: image: mariadb:11.4