From 2be02017d4e35f48e9b59ad142b968cbd351416f Mon Sep 17 00:00:00 2001 From: Sergio Visinoni Date: Fri, 16 Jan 2026 02:15:00 +0100 Subject: [PATCH] (Refactor) Extract file-specific information from book (#1734) * refactor(book): extract file-specific information from book First commit in a series aimed at refactoring the data model for books. More specifically, the idea is to extract all file-specific information from the `book` table and move it to the `book_file` table, previously named `book_additional_file` The aim is to make it easier to: * Improve support for books with multiple file formats (PDF, EPUB, etc) for all interactions (Read, Download, etc) * Support for merging/unmerging books * Add support for additional file types * Specify preferred formats at the user level Ref: #489 * refactor(book): ensure the API build and runs Further work on the refactoring aimed at separating file-related details from the `book` table. With this commit all the missing changes that were prventing the API to build or to book have been addressed. TODO: test extensively, adjust existing unit tests and add new ones * fix(read): add mapping for book format This restores the read functionality which relies on the book format field to decide which reader to use. * fix: fix read, dowload and file upload This commit fixes multiple issues either caused by the refactoring or pre-existing: * Fix the Read button behaviour after the refactoring * Unregister the watcher process when uploading additional formats * Fix downloading of additional book formats (using the wrong ID) * fix: adjust tests to use the new BookFileEntity class All the tests that used to fecth file information from BookEntity now need to get them from the relevant BookFileEntity * fix: do not rely on AdditionalFileType * Use the BookFileEntity bookFormat instead * fix: use the relevant BookFileEntity class * fix: call the right methods * fix: Add missing mapping for the test * fix: adapt the test to the new semantics All book files, including the primary one, are treated as equal now. The tests needs to take that into account when checking for additional formats. * fix: use mutable lists * fix: fix syntax for droppung unique constraint MariaDB uses indexes, not constraints * fix: regression on book file ordering We want to make this refactoring 100% compatible with the current behaviour (modulo a few bugs), therefore we need to maintain the right order to ensure the "primary" book stays the same after the migration. * fix: allow download of supplementary files * fix(opds): replace removed additionalFiles entity graph with bookFiles - Update BookOpdsRepository @EntityGraph paths to use BookEntity.bookFiles after the refactor - Add @DataJpaTest to validate BookOpdsRepository wiring and catch invalid EntityGraph attributes - Add H2 as testRuntimeOnly dependency so the JPA slice test can run with an embedded DB * chore(bookdrop): mount bookdrop folder from a local directory It's consistent with the library dir, and makes debugging easier when working on the local environment * fix: rename migration after rebase It's no longer 66, bumped to 73 * fix: handle BookEntity primary file NPEs after rebase Adjust tests to always instanciate BookFileEntity when manipulating BookEntity. * chore: rename migration to avoid conflict V73 is already taken on develop, V67 was left "unused" * chore: rename again to ensure it's applied * fix: make sure to flush the data to DB Without the flush there is a high chance of leaving the DB in an inconsistent state after a book move. * fix: move all files belonging to a book This fixes a pre-existing bug which has some nasty ramifications. We never moved "additional files" when changing library for a book entity, causing them to become effectively "unreacheable" from the UI. * fix(migration): remove the unique index before importing data * fix: fix build and test after rebase * fix(migration): drop legacy table * fix(upload): use the templetized name when storing on DB * fix(rebase): Add logical fixes post rebase * Adapt the code to properly handle the new `archive_type` field and logic * Bump the version number for the DB migration * Use `getPrimaryBookFile()` whenever trying to access book files * fix(migration): Handle additional book formats * Add support for FB2 files * Corretly handle cb7 files as CBX * fix(file mover): fix a regression when moving books across categories The previous approach would trigger the JPA's `orphanRemoval` parameter on the bookEntities, effetively triggering a delete on the DB. This caused files to be moved but the data on the DB would get stale, also causing additional formats to be treated as separate books upon a rescan. * fix(rescan): do not delete alternative files on rescan Upon library rescan, additional files such as images were removed from book entities association when using the "Each file is a book" library structure. --- booklore-api/build.gradle | 1 + .../controller/AdditionalFileController.java | 23 +-- .../booklore/mapper/AdditionalFileMapper.java | 11 +- .../booklore/mapper/BookMapper.java | 61 +++++--- .../booklore/mapper/v2/BookMapperV2.java | 13 ++ .../booklore/model/dto/Book.java | 4 +- .../{AdditionalFile.java => BookFile.java} | 7 +- .../booklore/model/entity/BookEntity.java | 57 ++++---- ...nalFileEntity.java => BookFileEntity.java} | 23 ++- .../BookAdditionalFileRepository.java | 45 ++++-- .../repository/BookOpdsRepository.java | 10 +- .../booklore/repository/BookRepository.java | 82 ++++++----- .../service/book/BookCreatorService.java | 15 +- .../service/book/BookDownloadService.java | 9 +- .../booklore/service/book/BookService.java | 122 +++++++++------- .../service/book/BookUpdateService.java | 86 +++++++---- .../service/file/AdditionalFileService.java | 21 ++- .../booklore/service/file/FileMoveHelper.java | 10 ++ .../service/file/FileMoveService.java | 85 ++++++++--- .../fileprocessor/AbstractFileProcessor.java | 2 +- .../service/fileprocessor/CbxProcessor.java | 12 +- .../service/fileprocessor/EpubProcessor.java | 6 +- .../service/fileprocessor/Fb2Processor.java | 4 +- .../service/fileprocessor/PdfProcessor.java | 8 +- .../kobo/KoboCompatibilityService.java | 8 +- .../service/kobo/KoboEntitlementService.java | 9 +- .../kobo/KoboLibrarySnapshotService.java | 2 +- .../service/library/BookDeletionService.java | 62 +++----- .../library/FolderAsBookFileProcessor.java | 32 ++--- .../library/LibraryProcessingService.java | 22 +-- .../service/library/LibraryRescanHelper.java | 6 +- .../service/metadata/BookCoverService.java | 14 +- .../service/metadata/BookMetadataService.java | 4 +- .../service/metadata/BookMetadataUpdater.java | 10 +- .../metadata/MetadataManagementService.java | 9 +- .../metadata/MetadataRefreshService.java | 6 +- .../PopulateFileHashesMigration.java | 6 +- .../PopulateMissingFileSizesMigration.java | 2 +- .../service/upload/FileUploadService.java | 74 +++++++--- .../watcher/BookFilePersistenceService.java | 5 +- .../booklore/util/FileUtils.java | 5 +- .../booklore/util/PathPatternResolver.java | 7 +- ...factor_book_and_book_alternative_files.sql | 107 ++++++++++++++ .../booklore/PathPatternResolverTest.java | 5 +- .../booklore/mapper/BookMapperTest.java | 27 +++- .../BookOpdsRepositoryDataJpaTest.java | 72 ++++++++++ .../service/AdditionalFileServiceTest.java | 57 ++++---- .../service/KoboEntitlementServiceTest.java | 9 +- .../service/book/BookServiceTest.java | 60 +++++--- .../service/book/BookUpdateServiceTest.java | 49 +++++-- .../file/FileMoveServiceOrderingTest.java | 20 ++- .../service/file/FileMoveServiceTest.java | 133 +++++++++++++++++- .../fileprocessor/CbxProcessorTest.java | 10 +- .../kobo/KoboCompatibilityServiceTest.java | 18 ++- .../kobo/KoboLibrarySnapshotServiceTest.java | 9 ++ .../FolderAsBookFileProcessorExampleTest.java | 4 +- .../FolderAsBookFileProcessorTest.java | 44 +++--- .../library/LibraryProcessingServiceTest.java | 89 +++++++++++- .../library/LibraryRescanHelperTest.java | 11 +- .../metadata/BookCoverServiceTest.java | 6 +- .../BookMetadataUpdaterCategoryTest.java | 12 ++ .../metadata/BookMetadataUpdaterTest.java | 66 +++++++++ .../writer/EpubMetadataWriterTest.java | 8 +- .../service/reader/CbxReaderServiceTest.java | 1 + .../service/upload/FileUploadServiceTest.java | 58 +++++--- .../booklore/util/FileUtilsTest.java | 10 +- .../util/PathPatternResolverTest.java | 8 +- .../util/builder/LibraryTestBuilder.java | 54 ++++--- .../builder/LibraryTestBuilderAssert.java | 53 ++++--- .../app/features/book/service/book.service.ts | 27 +++- dev.docker-compose.yml | 1 + 71 files changed, 1455 insertions(+), 573 deletions(-) rename booklore-api/src/main/java/com/adityachandel/booklore/model/dto/{AdditionalFile.java => BookFile.java} (75%) rename booklore-api/src/main/java/com/adityachandel/booklore/model/entity/{BookAdditionalFileEntity.java => BookFileEntity.java} (75%) create mode 100644 booklore-api/src/main/resources/db/migration/V91__Refactor_book_and_book_alternative_files.sql create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/repository/BookOpdsRepositoryDataJpaTest.java 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