(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.
This commit is contained in:
Sergio Visinoni
2026-01-16 02:15:00 +01:00
committed by GitHub
parent 99c8c131f6
commit 2be02017d4
71 changed files with 1455 additions and 573 deletions

View File

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

View File

@@ -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<List<AdditionalFile>> getAdditionalFiles(@PathVariable Long bookId) {
List<AdditionalFile> files = additionalFileService.getAdditionalFilesByBookId(bookId);
public ResponseEntity<List<BookFile>> getAdditionalFiles(@PathVariable Long bookId) {
List<BookFile> files = additionalFileService.getAdditionalFilesByBookId(bookId);
return ResponseEntity.ok(files);
}
@GetMapping(params = "type")
@GetMapping(params = "isBook")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<List<AdditionalFile>> getAdditionalFilesByType(
public ResponseEntity<List<BookFile>> getFilesByIsBook(
@PathVariable Long bookId,
@RequestParam AdditionalFileType type) {
List<AdditionalFile> files = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type);
@RequestParam boolean isBook) {
List<BookFile> 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<AdditionalFile> uploadAdditionalFile(
public ResponseEntity<BookFile> 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);
}

View File

@@ -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<AdditionalFile> toAdditionalFiles(List<BookAdditionalFileEntity> entities);
List<BookFile> toAdditionalFiles(List<BookFileEntity> entities);
@Named("mapFilePath")
default String mapFilePath(BookAdditionalFileEntity entity) {
default String mapFilePath(BookFileEntity entity) {
if (entity == null) return null;
try {
return entity.getFullFilePath().toString();

View File

@@ -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<String> mapAuthors(Set<AuthorEntity> authors) {
@@ -72,33 +74,54 @@ public interface BookMapper {
.build();
}
@Named("mapPrimaryBookType")
default BookFileType mapPrimaryBookType(List<BookFileEntity> bookFiles) {
BookFileEntity primary = getPrimaryBookFile(bookFiles);
return primary == null ? null : primary.getBookType();
}
@Named("mapAlternativeFormats")
default List<AdditionalFile> mapAlternativeFormats(List<BookAdditionalFileEntity> additionalFiles) {
if (additionalFiles == null) return null;
return additionalFiles.stream()
.filter(af -> AdditionalFileType.ALTERNATIVE_FORMAT.equals(af.getAdditionalFileType()))
.map(this::toAdditionalFile)
default List<BookFile> mapAlternativeFormats(List<BookFileEntity> 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<AdditionalFile> mapSupplementaryFiles(List<BookAdditionalFileEntity> additionalFiles) {
if (additionalFiles == null) return null;
return additionalFiles.stream()
.filter(af -> AdditionalFileType.SUPPLEMENTARY.equals(af.getAdditionalFileType()))
.map(this::toAdditionalFile)
default List<BookFile> mapSupplementaryFiles(List<BookFileEntity> 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<BookFileEntity> 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())

View File

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

View File

@@ -38,6 +38,6 @@ public class Book {
private String readStatus;
private Instant dateFinished;
private LibraryPath libraryPath;
private List<AdditionalFile> alternativeFormats;
private List<AdditionalFile> supplementaryFiles;
private List<BookFile> alternativeFormats;
private List<BookFile> supplementaryFiles;
}

View File

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

View File

@@ -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<BookRecommendationLite> similarBooksJson;
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<BookAdditionalFileEntity> additionalFiles;
@OrderBy("id ASC")
@Builder.Default
private List<BookFileEntity> bookFiles = new ArrayList<>();
@OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
private List<UserBookProgressEntity> 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<Path> 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();
}
}

View File

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

View File

@@ -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<BookAdditionalFileEntity, Long> {
public interface BookAdditionalFileRepository extends JpaRepository<BookFileEntity, Long> {
List<BookAdditionalFileEntity> findByBookId(Long bookId);
List<BookFileEntity> findByBookId(Long bookId);
List<BookAdditionalFileEntity> findByBookIdAndAdditionalFileType(Long bookId, AdditionalFileType additionalFileType);
List<BookFileEntity> findByBookIdAndIsBookFormat(Long bookId, boolean isBookFormat);
List<BookFileEntity> 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.
* <p>
* 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<BookAddition
* @param altFormatCurrentHash The current hash of the file, which is only considered for alternative format files.
* @return an {@link Optional} containing the found entity, or an empty {@link Optional} if no match is found.
*/
Optional<BookAdditionalFileEntity> findByAltFormatCurrentHash(String altFormatCurrentHash);
Optional<BookFileEntity> findByAltFormatCurrentHash(String altFormatCurrentHash);
@Query("SELECT af FROM BookAdditionalFileEntity af WHERE af.book.libraryPath.id = :libraryPathId AND af.fileSubPath = :fileSubPath AND af.fileName = :fileName")
Optional<BookAdditionalFileEntity> 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<BookFileEntity> findByLibraryPath_IdAndFileSubPathAndFileName(@Param("libraryPathId") Long libraryPathId,
@Param("fileSubPath") String fileSubPath,
@Param("fileName") String fileName);
List<BookAdditionalFileEntity> findByAdditionalFileType(AdditionalFileType additionalFileType);
List<BookFileEntity> 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<BookFileEntity> findByBookType(BookFileType bookType);
@Query("SELECT af FROM BookAdditionalFileEntity af WHERE af.book.library.id = :libraryId")
List<BookAdditionalFileEntity> 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<BookFileEntity> 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);
}

View File

@@ -23,7 +23,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
@Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC")
Page<Long> 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<BookEntity> findAllWithMetadataByIds(@Param("ids") Collection<Long> ids);
@@ -43,7 +43,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, 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<Long> findBookIdsByLibraryIds(@Param("libraryIds") Collection<Long> 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<BookEntity> findAllWithMetadataByIdsAndLibraryIds(@Param("ids") Collection<Long> ids, @Param("libraryIds") Collection<Long> libraryIds);
@@ -63,7 +63,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, 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<Long> 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<BookEntity> findAllWithMetadataByIdsAndShelfId(@Param("ids") Collection<Long> ids, @Param("shelfId") Long shelfId);
@@ -81,7 +81,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
""")
Page<Long> 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<BookEntity> findAllWithFullMetadataByIds(@Param("ids") Collection<Long> ids);
@@ -101,7 +101,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
""")
Page<Long> findBookIdsByMetadataSearchAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> 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<BookEntity> findAllWithFullMetadataByIdsAndLibraryIds(@Param("ids") Collection<Long> ids, @Param("libraryIds") Collection<Long> libraryIds);

View File

@@ -20,16 +20,22 @@ import java.util.Set;
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
Optional<BookEntity> findBookByIdAndLibraryId(long id, long libraryId);
Optional<BookEntity> 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<BookEntity> 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<BookEntity> findByCurrentHash(@Param("currentHash") String currentHash);
Optional<BookEntity> 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<Long> findBookIdsByLibraryId(@Param("libraryId") long libraryId);
List<BookEntity> 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<BookEntity> 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<BookEntity> findByLibraryPath_IdAndFileSubPathAndFileName(@Param("libraryPathId") Long libraryPathId,
@Param("fileSubPath") String fileSubPath,
@Param("fileName") String fileName);
@@ -37,32 +43,32 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Query("SELECT b.id FROM BookEntity b WHERE b.libraryPath.id IN :libraryPathIds AND (b.deleted IS NULL OR b.deleted = false)")
List<Long> findAllBookIdsByLibraryPathIdIn(@Param("libraryPathIds") Collection<Long> 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<BookEntity> 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<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> 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<BookEntity> findWithMetadataByIdsWithPagination(@Param("bookIds") Set<Long> 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<BookEntity> 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<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> 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<BookEntity> 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<BookEntity> findAllWithMetadataByFileSizeKbIsNull();
@Query("""
@@ -105,31 +111,17 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, 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<BookEntity> findByLibraryIdAndLibraryPathIdAndFileSubPathAndFileName(
@Param("libraryId") Long libraryId,
@Param("libraryPathId") Long libraryPathId,
@@ -139,7 +131,13 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, 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<Long> 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<BookEntity, Long>, 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<BookCoverUpdateProjection> findCoverUpdateInfoByIds(@Param("bookIds") Collection<Long> 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);
}

View File

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

View File

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

View File

@@ -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<BookEntity> books = bookQueryService.findAllWithMetadataByIds(ids);
List<Long> 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<Path> 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<Path> 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<Path> 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());
}
}

View File

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

View File

@@ -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<AdditionalFile> getAdditionalFilesByBookId(Long bookId) {
List<BookAdditionalFileEntity> entities = additionalFileRepository.findByBookId(bookId);
public List<BookFile> getAdditionalFilesByBookId(Long bookId) {
List<BookFileEntity> entities = additionalFileRepository.findByBookId(bookId);
return additionalFileMapper.toAdditionalFiles(entities);
}
public List<AdditionalFile> getAdditionalFilesByBookIdAndType(Long bookId, AdditionalFileType type) {
List<BookAdditionalFileEntity> entities = additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type);
public List<BookFile> getAdditionalFilesByBookIdAndIsBook(Long bookId, boolean isBook) {
List<BookFileEntity> entities = additionalFileRepository.findByBookIdAndIsBookFormat(bookId, isBook);
return additionalFileMapper.toAdditionalFiles(entities);
}
@Transactional
public void deleteAdditionalFile(Long fileId) {
Optional<BookAdditionalFileEntity> fileOpt = additionalFileRepository.findById(fileId);
Optional<BookFileEntity> 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<Resource> downloadAdditionalFile(Long fileId) throws IOException {
Optional<BookAdditionalFileEntity> fileOpt = additionalFileRepository.findById(fileId);
Optional<BookFileEntity> 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)) {

View File

@@ -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<Path> libraryRoots) {
Path dir = currentDir.toAbsolutePath().normalize();
Set<Path> normalizedRoots = new HashSet<>();

View File

@@ -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<Long, PlannedMove> plannedMovesByBookFileId = new HashMap<>();
Set<Path> sourceParentsToCleanup = new HashSet<>();
try {
Optional<BookEntity> optionalBook = bookRepository.findById(bookId);
Optional<BookEntity> optionalBook = bookRepository.findByIdWithBookFiles(bookId);
Optional<LibraryEntity> 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());
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BookAdditionalFileEntity> additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds);
List<BookFileEntity> additionalFiles = bookAdditionalFileRepository.findAllById(additionalFileIds);
bookAdditionalFileRepository.deleteAll(additionalFiles);
entityManager.flush();
entityManager.clear();
@@ -97,49 +95,31 @@ public class BookDeletionService {
}
private boolean tryPromoteAlternativeFormatToBook(BookEntity book, List<LibraryFile> libraryFiles) {
List<BookAdditionalFileEntity> existingAlternativeFormats = findExistingAlternativeFormats(book, libraryFiles);
if (existingAlternativeFormats.isEmpty()) {
return false;
}
BookAdditionalFileEntity promotedFormat = existingAlternativeFormats.getFirst();
promoteAlternativeFormatToBook(book, promotedFormat);
bookAdditionalFileRepository.delete(promotedFormat);
log.info("Promoted alternative format {} to main book for book ID {}", promotedFormat.getFileName(), book.getId());
return true;
}
private List<BookAdditionalFileEntity> findExistingAlternativeFormats(BookEntity book, List<LibraryFile> libraryFiles) {
Set<String> currentFileNames = libraryFiles.stream()
Set<String> existingFileNames = libraryFiles.stream()
.map(LibraryFile::getFileName)
.collect(Collectors.toSet());
if (book.getAdditionalFiles() == null) {
return Collections.emptyList();
List<BookFileEntity> 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)) {

View File

@@ -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<LibraryFile> 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<BookEntity> 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<CreateBookResult> 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<BookEntity> 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<LibraryFile> filesInDirectory) {
for (LibraryFile file : filesInDirectory) {
Optional<BookFileExtension> 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<BookAdditionalFileEntity> existingFile = bookAdditionalFileRepository
private void createAdditionalFileIfNotExists(BookEntity bookEntity, LibraryFile file, boolean isBook, BookFileType bookType) {
Optional<BookFileEntity> 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);
}

View File

@@ -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<LibraryFile> libraryFiles = libraryFileHelper.getLibraryFiles(libraryEntity, processor);
List<Long> additionalFileIds = detectDeletedAdditionalFiles(libraryFiles, libraryEntity);
List<Long> 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<Long> detectDeletedAdditionalFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
Set<String> currentFileNames = libraryFiles.stream()
.map(LibraryFile::getFileName)
protected List<Long> detectDeletedAdditionalFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity, LibraryFileProcessor processor) {
Set<String> currentFileKeys = libraryFiles.stream()
.map(this::generateUniqueKey)
.collect(Collectors.toSet());
List<BookAdditionalFileEntity> allAdditionalFiles = bookAdditionalFileRepository.findByLibraryId(libraryEntity.getId());
List<BookFileEntity> 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());
}
}

View File

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

View File

@@ -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<BookRegenerationInfo> 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<BookRegenerationInfo> getUnlockedBookRegenerationInfos(Set<Long> 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);
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("\\", "/");

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BookEntity> result = bookOpdsRepository.findAllWithMetadataByIds(List.of(book.getId()));
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(book.getId());
}
}

View File

@@ -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<BookAdditionalFileEntity> entities = List.of(fileEntity);
List<AdditionalFile> expectedFiles = List.of(additionalFile);
List<BookFileEntity> entities = List.of(fileEntity);
List<BookFile> expectedFiles = List.of(additionalFile);
when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities);
when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles);
List<AdditionalFile> result = additionalFileService.getAdditionalFilesByBookId(bookId);
List<BookFile> 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<BookAdditionalFileEntity> entities = Collections.emptyList();
List<AdditionalFile> expectedFiles = Collections.emptyList();
List<BookFileEntity> entities = Collections.emptyList();
List<BookFile> expectedFiles = Collections.emptyList();
when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities);
when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles);
List<AdditionalFile> result = additionalFileService.getAdditionalFilesByBookId(bookId);
List<BookFile> 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<BookAdditionalFileEntity> entities = List.of(fileEntity);
List<AdditionalFile> expectedFiles = List.of(additionalFile);
boolean isBook = true;
List<BookFileEntity> entities = List.of(fileEntity);
List<BookFile> 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<AdditionalFile> result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type);
List<BookFile> 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<BookAdditionalFileEntity> entities = Collections.emptyList();
List<AdditionalFile> expectedFiles = Collections.emptyList();
boolean isBook = false;
List<BookFileEntity> entities = Collections.emptyList();
List<BookFile> expectedFiles = Collections.emptyList();
when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities);
when(additionalFileRepository.findByBookIdAndIsBookFormat(bookId, isBook)).thenReturn(entities);
when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles);
List<AdditionalFile> result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type);
List<BookFile> 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));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BookAdditionalFileEntity> additionalFileCaptor;
private ArgumentCaptor<BookFileEntity> additionalFileCaptor;
private MockedStatic<FileUtils> fileUtilsMock;
private MockedStatic<FileFingerprint> fileFingerprintMock;

View File

@@ -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<BookAdditionalFileEntity> additionalFileCaptor;
private ArgumentCaptor<BookFileEntity> additionalFileCaptor;
private MockedStatic<FileUtils> fileUtilsMock;
private MockedStatic<FileFingerprint> fileFingerprintMock;
@@ -134,12 +133,12 @@ class FolderAsBookFileProcessorTest {
verify(adminEventBroadcaster, never()).broadcastAdminEvent(anyString());
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
List<BookFileEntity> 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<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
List<BookFileEntity> 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<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
List<BookFileEntity> 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<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName)
List<BookFileEntity> 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,7 @@ public class LibraryTestBuilder {
private final Map<Path, String> libraryFileHashes = new HashMap<>();
private final Map<Long, BookEntity> bookRepository = new HashMap<>();
private final Map<String, BookEntity> bookMap = new HashMap<>();
private final Map<Long, BookAdditionalFileEntity> bookAdditionalFileRepository = new HashMap<>();
private final Map<Long, BookFileEntity> bookAdditionalFileRepository = new HashMap<>();
public LibraryTestBuilder(MockedStatic<FileUtils> fileUtilsMock,
MockedStatic<FileFingerprint> 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<BookAdditionalFileEntity> getBookAdditionalFiles() {
public List<BookFileEntity> 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()

View File

@@ -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<LibraryTestBuilderAssert, LibraryTestBuilder> {
@@ -41,14 +40,15 @@ public class LibraryTestBuilderAssert extends AbstractAssert<LibraryTestBuilderA
.describedAs("Book with title '%s' should exist", bookTitle)
.isNotNull();
Assertions.assertThat(book.getAdditionalFiles()
.stream()
.filter(a -> a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT)
.map(BookAdditionalFileEntity::getFileName)
.map(BookFileExtension::fromFileName)
.filter(Optional::isPresent)
.map(Optional::get)
.map(BookFileExtension::getType))
List<BookFileType> 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<LibraryTestBuilderA
.describedAs("Book with title '%s' should exist", bookTitle)
.isNotNull();
Assertions.assertThat(book.getAdditionalFiles()
Assertions.assertThat(book.getBookFiles()
.stream()
.filter(a -> 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<LibraryTestBuilderA
.describedAs("Book with title '%s' should exist", bookTitle)
.isNotNull();
Assertions.assertThat(book.getAdditionalFiles()
.stream()
.filter(a -> a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT))
List<BookFileType> 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<LibraryTestBuilderA
.describedAs("Book with title '%s' should exist", bookTitle)
.isNotNull();
Assertions.assertThat(book.getAdditionalFiles())
Assertions.assertThat(book.getBookFiles())
.describedAs("Book '%s' should have no supplementary files", bookTitle)
.noneMatch(a -> a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY);
.noneMatch(a -> !a.isBookFormat());
return this;
}
@@ -112,9 +118,10 @@ public class LibraryTestBuilderAssert extends AbstractAssert<LibraryTestBuilderA
.describedAs("Book with title '%s' should exist", bookTitle)
.isNotNull();
Assertions.assertThat(book.getAdditionalFiles())
Assertions.assertThat(book.getBookFiles())
.describedAs("Book '%s' should have no additional files", bookTitle)
.isEmpty();
.allMatch(BookFileEntity::isBookFormat)
.containsOnly(book.getPrimaryBookFile());
return this;
}

View File

@@ -334,7 +334,25 @@ export class BookService {
uploadAdditionalFile(bookId: number, file: File, fileType: AdditionalFileType, description?: string): Observable<AdditionalFile> {
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!);
}

View File

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